Files
feuerwerk/tests/viewport/render-layers.test.ts
T
adminandClaude Opus 5.5 b2326085ed
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 14m26s
Show each camera only its layers and lay overlay cameras on top (#302)
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>
2026-10-03 15:32:52 +02:00

137 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { SetLayerNamesCommand } from '../../src/core/commands/projectCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import {
cameraSees,
INHERIT_LAYER,
OVERLAY_KEY,
ownRenderLayer,
RENDER_LAYER_KEY,
renderLayersOf,
SEES_KEY,
} from '../../src/core/model/renderLayers';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { settingsChanges } from '../../src/editor/ProjectSettingsDialog';
import { cameraOfItems, overlaysOfItems, overlaysOfWorld } from '../../src/viewport/activeCamera';
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
import { cameraView, overlayView } from '../../src/viewport/view';
import { makeSpriteScene, place, startWorld } from '../helpers';
function sceneWithCamera() {
const fixture = makeSpriteScene();
const camera = new CreateAssetCommand('camera', 'Cam');
fixture.stack.execute(camera);
const container = new CreateAssetCommand('container', 'Hud');
fixture.stack.execute(container);
return {
...fixture,
cameraId: camera.asset.id,
containerId: container.asset.id,
set: (objectId: string, key: string, value: number | boolean) =>
fixture.stack.execute(new SetOverrideCommand(fixture.sceneId, objectId, key, value)),
items: () => collectHolderItems(fixture.project, fixture.sceneOf(), 0).items,
};
}
describe('render layers (#302)', () => {
it('reads the names like the collision layers, "Default" for a project from before', () => {
expect(renderLayersOf(null)).toEqual(['Default']);
expect(renderLayersOf({ renderLayers: ['World', '', 'UI'] })).toEqual(['World', 'Layer 1', 'UI']);
const f = sceneWithCamera();
const stored = serializeProject(f.project) as Record<string, unknown>;
delete stored.renderLayers;
expect(deserializeProject(stored).renderLayers).toEqual(['Default']);
});
it('reads an own choice, and anything unreadable as none', () => {
for (const value of [undefined, null, '', ' ', 'x', -1, -5, 16, true])
expect(ownRenderLayer(value)).toBe(INHERIT_LAYER);
expect(ownRenderLayer(0)).toBe(0);
expect(ownRenderLayer(3)).toBe(3);
expect(ownRenderLayer('2')).toBe(2);
});
it('puts an object without a choice on its parent’s layer, and the top on layer 0', () => {
const f = sceneWithCamera();
const hud = place(f, f.containerId, { x: 0, y: 0 });
const inside = place(f, f.spriteId, { x: 0, y: 0 }, hud);
const own = place(f, f.spriteId, { x: 0, y: 0 }, hud);
const loose = place(f, f.spriteId, { x: 0, y: 0 });
f.set(hud, RENDER_LAYER_KEY, 1);
f.set(own, RENDER_LAYER_KEY, 2);
const layerOf = (id: string) => f.items().find((item) => item.id === id)!.renderLayer;
expect([layerOf(hud), layerOf(inside), layerOf(own), layerOf(loose)]).toEqual([1, 1, 2, 0]);
const { world } = startWorld(f);
const running = (id: string) =>
collectWorldItems(world).items.find((item) => item.id === id)!.renderLayer;
expect([running(inside), running(own), running(loose)]).toEqual([1, 2, 0]);
});
it('lets a camera without `Sees` see every layer, and one with it only those', () => {
expect(cameraSees({}, 0)).toBe(true);
expect(cameraSees({}, 15)).toBe(true);
expect(cameraSees({ [SEES_KEY]: 0b10 }, 0)).toBe(false);
expect(cameraSees({ [SEES_KEY]: 0b10 }, 1)).toBe(true);
});
it('keeps the first camera without Overlay as the main one, the overlays apart in hierarchy order', () => {
const f = sceneWithCamera();
const over = place(f, f.cameraId, { x: 0, y: 0 });
const main = place(f, f.cameraId, { x: 100, y: 0 });
const plain = place(f, f.cameraId, { x: 200, y: 0 });
const over2 = place(f, f.cameraId, { x: 300, y: 0 });
f.set(over, OVERLAY_KEY, true);
f.set(over2, OVERLAY_KEY, true);
const { world } = startWorld(f);
expect(world.activeCamera()?.objectId).toBe(main);
expect(world.overlayCameras().map((c) => c.objectId)).toEqual([over, over2]);
expect(overlaysOfWorld(world).map((c) => c.x)).toEqual([0, 300]);
expect(cameraOfItems(f.items())?.id).toBe(main);
expect(overlaysOfItems(f.items()).map((c) => c.id)).toEqual([over, over2]);
// Two plain cameras on draw as before: through the first only, the second is no overlay.
expect(world.overlayCameras().map((c) => c.objectId)).not.toContain(plain);
f.set(over2, 'enabled', false);
world.syncFromModel();
expect(world.overlayCameras().map((c) => c.objectId)).toEqual([over]);
});
it('fits an overlay into the main picture, and gives the same camera the very same view', () => {
const props = { width: 640, height: 360, zoom: 1 };
const main = cameraView({ x: 0, y: 0, props }, 1280, 720, 2);
expect(overlayView({ x: 0, y: 0, props }, main, { w: 640, h: 360 })).toEqual(main);
// Narrower and zoomed: the frame is 160x180 world units, fitted to the 720 px height.
const narrow = overlayView({ x: 50, y: 7, props: { width: 320, height: 360, zoom: 2 } }, main, {
w: 640,
h: 360,
});
expect(narrow).toEqual({ x: 50, y: 7, scale: 4, rect: { x: 320, y: 0, w: 640, h: 720 } });
});
it('keeps the render layers an undo step and a change of the settings of their own', () => {
const f = sceneWithCamera();
f.stack.execute(new SetLayerNamesCommand('renderLayers', ['World', 'UI']));
expect(f.project.renderLayers).toEqual(['World', 'UI']);
expect(f.project.collisionLayers).toEqual(['Default']);
f.stack.undo();
expect(f.project.renderLayers).toEqual(['Default']);
const before = {
name: 'P',
playFrom: 'start' as const,
whilePlaying: 'live' as const,
gameLinkZoom: 'auto' as const,
collisionLayers: ['Default'],
renderLayers: ['Default'],
symbolScale: 1,
};
expect(settingsChanges(before, { ...before, renderLayers: ['Default'] })).toEqual({});
expect(settingsChanges(before, { ...before, renderLayers: ['Default', 'UI'] })).toEqual({
renderLayers: ['Default', 'UI'],
});
});
});