Show each camera only its layers and lay overlay cameras on top (#302)
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 14m26s

A camera could only show the whole scene, so a HUD drawn in the world
would run through a camera shader (#299) with everything else. Render
layers say which cameras see an object, and overlay cameras draw their
own picture over the main one.

- The project has a list of up to 16 render layers, edited in Project
  settings next to the collision layers. It is a list of its own, read
  by the same rules (`layerNamesOf`).
- Sprites, texts, tilemaps, particle sources and containers carry a row
  `Render layer` under their switches. Its default `Inherit` takes the
  parent's layer, at the top the first one.
- A camera has `Sees` (all layers by default) and `Overlay`. The main
  camera is the first one switched on without Overlay, so two plain
  cameras still draw through the first one only.
- Every view through a camera keeps only what that camera sees and lays
  one see-through view per overlay camera over itself. That view is a
  `WorldViewport` of its own, handed its view and frame from below.
- An overlay is fitted into the main camera's whole picture without
  distortion. One of the same size and zoom covers it exactly.
- `z` sorts within one camera only. The pictures lie on top of each
  other as a whole, which is what a shader per camera (#299) needs.
- The Game tab's preview now collects the scene once per redraw. Before
  it collected it twice, with the cameras it would have been five times.
- `SetCollisionLayersCommand` became `SetLayerNamesCommand`, which
  takes the list it sets.

Follow-ups: #304 (the mouse through a chosen camera) and #305 (change
the render layer at run time).

Tests:
- Gate: 226 files, 3487 tests (3480 + 7)
- e2e 63 (new): 4 checks. A green overlay sprite at z -10 covers a
  red main sprite at z 1000, in the preview and in the running game.
  A `UI` sprite in front of the main camera and a `Default` one in
  front of the overlay stay away. Without Overlay the second camera
  draws nothing. The settings dialog adds a render layer at the end.
- Counter-checks: inheriting from parents switched off fails 1 unit
  test. Overlays never drawn fails the two overlay checks in e2e 63.
- e2e total 1720 (1716 + 4), computed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 15:32:52 +02:00
1 parent f03ca0d382
commit b2326085ed
33 files changed
+802 -115

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
/**
* Render layers and overlay cameras (#302). The main camera sees only `Default`, an overlay camera
* 2000 units to the right sees only `UI`. Each draws its own picture, the overlay's laid on top:
* a green sprite of the overlay with z -10 covers a red one of the main camera with z 1000, and
* what a camera does not see stays away – a yellow `UI` sprite in front of the main camera, a blue
* `Default` one in front of the overlay. Switched to no overlay, the second camera draws nothing.
*
* Everything it adds carries an `rl63` id and goes again at the end.
*/
import { MAKE_OBJECT } from './lib/scaffold.mjs';
const OVERLAY = 'rl63-cam';
export async function renderLayers(t) {
const { check, json, evaluate, mutate, sleep, shot, showTab, play, stop, clickText, press } = t;
await showTab('scene');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const main = scene.objects.find((o) => o.id === 'o3');
window.__rl63 = { layers: p.renderLayers, overrides: structuredClone(main.overrides) };
p.renderLayers = ['Default', 'UI'];
main.overrides = { ...main.overrides, sees: 0b01 };
${MAKE_OBJECT}
const sprite = p.assets.find((a) => a.type === 'sprite');
const square = (id, name, dx, z, size, color, layer, from) => {
const o = make(id, sprite.id, '', 0, name);
Object.assign(o.position, { x: from.x + dx, y: from.y, z });
o.overrides = { width: size, height: size, color, texture: '', shader: '', renderLayer: layer };
return o;
};
const overlay = structuredClone(main);
Object.assign(overlay, { id: '${OVERLAY}', name: 'Overlay 63', instanceEdits: {} });
overlay.position = { ...main.position, x: main.position.x + 2000 };
overlay.overrides = { ...main.overrides, overlay: true, sees: 0b10 };
scene.objects.push(
overlay,
square('rl63-a', 'A 63', 70, 1000, 60, 0xff0000, -1, main.position),
square('rl63-d', 'D 63', -70, 1000, 60, 0xffff00, 1, main.position),
square('rl63-b', 'B 63', 0, -10, 40, 0x00ff00, 1, overlay.position),
square('rl63-c', 'C 63', 70, 1000, 60, 0x0000ff, 0, overlay.position),
);
`);
await evaluate('__gm.state.select(null)');
await showTab('game');
await sleep(300);
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const is = (hex, [r, g, b]) => {
const [pr, pg, pb] = channels(hex);
return Math.abs(pr - r) < 30 && Math.abs(pg - g) < 30 && Math.abs(pb - b) < 30;
};
/** The three points of the picture the squares sit on: the centre and 70 units either side. */
const picture = async () => {
const { frame, scale } = await json('__gm.gameScale()');
const cx = Math.round(frame.x + frame.w / 2);
const cy = Math.round(frame.y + frame.h / 2);
const at = (dx) => json(`__gm.pixelAt(${Math.round(cx + dx * scale)}, ${cy})`);
return {
centre: await at(0),
right: await at(70),
left: await at(-70),
main: await json('__gm.gameDrawOrder()'),
overlays: await json('__gm.gameOverlays()'),
};
};
const GREEN = [0, 255, 0];
const RED = [255, 0, 0];
const YELLOW = [255, 255, 0];
const stacked = (seen) =>
seen.overlays.length === 1 &&
seen.overlays[0].camera === OVERLAY &&
seen.overlays[0].drawOrder.includes('rl63-b') &&
!seen.overlays[0].drawOrder.includes('rl63-c') &&
seen.main.includes('rl63-a') &&
!seen.main.includes('rl63-d') &&
!seen.main.includes('rl63-b') &&
is(seen.centre, GREEN) &&
is(seen.right, RED) &&
!is(seen.left, YELLOW);
// 63a. The preview of the Game tab: each camera draws what it sees, the overlay on top.
const preview = await picture();
await shot('63-overlay-preview');
check(
'each camera draws only its layers, and the overlay lies on top whatever the z',
stacked(preview),
JSON.stringify(preview),
);
// 63b. Without Overlay the second camera is a plain one after the first: it draws nothing.
await mutate(`
const cam = __gm.state.activeScene.objects.find((o) => o.id === '${OVERLAY}');
cam.overrides = { ...cam.overrides, overlay: false };
`);
await sleep(200);
const plain = await picture();
check(
'two plain cameras switched on draw through the first one only',
plain.overlays.length === 0 && !is(plain.centre, GREEN) && is(plain.right, RED),
JSON.stringify(plain),
);
await mutate(`
const cam = __gm.state.activeScene.objects.find((o) => o.id === '${OVERLAY}');
cam.overrides = { ...cam.overrides, overlay: true };
`);
// 63c. The running game stacks them the same way.
await play();
await sleep(400);
const running = await picture();
check(
'the running game lays the overlay camera on top the same way',
stacked(running),
JSON.stringify(running),
);
await stop();
// 63d. The project settings list the render layers, apart from the collision layers.
await t.clickToolbar('menu');
await clickText('Project settings…');
const opened = await json('__gm.renderLayers()');
await clickText('Add render layer');
const added = await json('__gm.renderLayers()');
const collision = await json('__gm.collisionLayers()');
await press('Escape');
await sleep(150);
check(
'the project settings list the render layers and add one at the end',
JSON.stringify(opened.dialog) === JSON.stringify(['Default', 'UI']) &&
JSON.stringify(added.dialog) === JSON.stringify(['Default', 'UI', 'Layer 2']) &&
!collision.dialog.includes('UI'),
JSON.stringify({ opened, added, collision }),
);
// Clean up.
await showTab('scene');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('rl63-'));
scene.objects.find((o) => o.id === 'o3').overrides = window.__rl63.overrides;
p.renderLayers = window.__rl63.layers;
`);
}
+2
View File
@@ -65,6 +65,7 @@ import { whilePlaying } from './59-while-playing.mjs';
import { release } from './60-release.mjs';
import { fullscreen } from './61-fullscreen.mjs';
import { devicePixels } from './62-device-pixels.mjs';
import { renderLayers } from './63-render-layers.mjs';
export const STEPS = [
hierarchy,
@@ -129,4 +130,5 @@ export const STEPS = [
release,
fullscreen,
devicePixels,
renderLayers,
];