A shader so far worked only on its own wearer. Effects over the whole game picture, such as a vignette, grey, scanlines or a red flash on a hit, had no place to live. A camera now wears one, drawn the way a field shader of a particle source is (#170). - The camera gets a Shader row and, while one is picked, a Shader resolution row. The shader's properties follow as camera rows. They load, animate and appear in a Properties node under Type Camera. - Every view keeps masks and objects in one container. CameraShade renders it into the camera's picture, cleared to the background, and draws the shader over the frame. Game tab and play.html take the container off the stage while the picture stands in for it. - Screen pixels gives the picture as many pixels as it covers on screen. Camera pixels uses View width x View height and scales up without smoothing. - A pixel counts as a camera pixel in both (the user's choice). The uniform uPixelScale makes Texture size read View width x View height, so a Blur keeps its reach at any game scale. - Each camera wears its own shader, overlay cameras included. A red flash on the game camera leaves the HUD of an overlay camera alone. - The scene view's eye has a Shader switch on the Camera row, off by default. With it on, the main camera's shader lies over its frame, below symbols and gizmos, and the world around it stays drawn. - New tool tools/perf-camera-shader.mjs measures the cost. Measured on stein2 (HD 630, Game tab at Fit 1310x679): a camera shader that hands the picture through stays inside the scene's own spread of 3 to 5 fps between runs. A Blur of 4 cost 4 to 8 fps. Tests: - Gate on melektaus-atx: 227 files, 3497 tests (3487 + 10). - e2e 63 alone: 11 checks (6 + 5), e2e sum 1725 counted. - 63e-63i: pass-through is byte-equal and upright at 100%, flat red reaches the corners while the overlay keeps its green, Texture size reads 640 x 360 in both resolutions at Fit, no shader draws as before, and the scene view shows it only with the switch on. - Counter-checks: a camera left out of the shader wearers fails 1 unit test. Counting screen pixels fails 63g with #ab6000. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
352 lines
15 KiB
JavaScript
352 lines
15 KiB
JavaScript
/**
|
||
* 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.
|
||
* Then the main camera wears a shader over its picture (#299), in the Game tab and, switched on in
|
||
* the eye, in the scene view.
|
||
*
|
||
* 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 }),
|
||
);
|
||
|
||
// 63e. (#299) The main camera wears a shader over its whole picture. One that changes nothing
|
||
// draws the picture byte for byte as before, the right way up: a blue square 100 units above
|
||
// the centre stays above it. At 100 % so the picture's pixels sit on the screen's.
|
||
await showTab('game');
|
||
const scaleBefore = await json('__gm.state.gameScale');
|
||
await mutate(`
|
||
${MAKE_OBJECT}
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
const main = scene.objects.find((o) => o.id === 'o3');
|
||
const sprite = p.assets.find((a) => a.type === 'sprite');
|
||
const o = make('rl63-e', sprite.id, '', 0, 'E 63');
|
||
Object.assign(o.position, { x: main.position.x, y: main.position.y - 100, z: 0 });
|
||
o.overrides = { width: 30, height: 30, color: 0x3366ff, texture: '', shader: '', renderLayer: 0 };
|
||
scene.objects.push(o);
|
||
__gm.state.setGameScale(1);
|
||
`);
|
||
const shaderOf = (id, nodes, edges) => ({
|
||
id,
|
||
type: 'shader',
|
||
name: id,
|
||
props: {},
|
||
graph: { nodes, edges },
|
||
});
|
||
const link = (from, out, to, port) => ({ from: { node: from, port: out }, to: { node: to, port } });
|
||
const PASS = shaderOf('rl63-pass', [{ id: 'out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], []);
|
||
const FLAT_RED = shaderOf(
|
||
'rl63-red',
|
||
[
|
||
{ id: 'red', type: 'ShaderColor', x: 0, y: 0, params: { value: 0xff0000 } },
|
||
{ id: 'one', type: 'ShaderNumber', x: 0, y: 80, params: { value: 1 } },
|
||
{ id: 'out', type: 'ShaderOutput', x: 200, y: 0, params: {} },
|
||
],
|
||
[link('red', 'color', 'out', 'color'), link('one', 'value', 'out', 'alpha')],
|
||
);
|
||
// Red is the width, green the height, both in thousandths: 640 × 360 comes out as 163 / 92.
|
||
const SIZE = shaderOf(
|
||
'rl63-size',
|
||
[
|
||
{ id: 'size', type: 'ShaderTextureSize', x: 0, y: 0, params: {} },
|
||
{ id: 'red', type: 'ShaderColor', x: 0, y: 80, params: { value: 0xff0000 } },
|
||
{ id: 'green', type: 'ShaderColor', x: 0, y: 160, params: { value: 0x00ff00 } },
|
||
{ id: 'milli', type: 'ShaderNumber', x: 0, y: 240, params: { value: 0.001 } },
|
||
{ id: 'one', type: 'ShaderNumber', x: 0, y: 320, params: { value: 1 } },
|
||
{ id: 'w', type: 'ShaderMultiply', x: 200, y: 0, params: {} },
|
||
{ id: 'h', type: 'ShaderMultiply', x: 200, y: 160, params: {} },
|
||
{ id: 'sum', type: 'ShaderAdd', x: 400, y: 0, params: {} },
|
||
{ id: 'scaled', type: 'ShaderMultiply', x: 600, y: 0, params: {} },
|
||
{ id: 'out', type: 'ShaderOutput', x: 800, y: 0, params: {} },
|
||
],
|
||
[
|
||
link('red', 'color', 'w', 'a'),
|
||
link('size', 'width', 'w', 'b'),
|
||
link('green', 'color', 'h', 'a'),
|
||
link('size', 'height', 'h', 'b'),
|
||
link('w', 'result', 'sum', 'a'),
|
||
link('h', 'result', 'sum', 'b'),
|
||
link('sum', 'result', 'scaled', 'a'),
|
||
link('milli', 'value', 'scaled', 'b'),
|
||
link('scaled', 'result', 'out', 'color'),
|
||
link('one', 'value', 'out', 'alpha'),
|
||
],
|
||
);
|
||
/** Puts `shader` on the main camera (null takes it off), with `resolution` as its row. */
|
||
const wear = (shader, resolution = 'screen') =>
|
||
mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets = p.assets.filter((a) => !a.id.startsWith('rl63-'));
|
||
const shader = ${JSON.stringify(shader)};
|
||
if (shader) p.assets.push(shader);
|
||
const main = __gm.state.activeScene.objects.find((o) => o.id === 'o3');
|
||
main.overrides = { ...main.overrides, shader: shader ? shader.id : '', shaderResolution: '${resolution}' };
|
||
`);
|
||
/** Two rows of the picture, through the centre and through the blue square, and the square's spot. */
|
||
const rows = 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 row = (dy) =>
|
||
json(`__gm.pixelRow(${Math.round(frame.x)}, ${Math.round(cy + dy * scale)}, ${Math.round(frame.w)})`);
|
||
return {
|
||
centre: await row(0),
|
||
above: await row(-100),
|
||
blueAbove: await json(`__gm.pixelAt(${cx}, ${Math.round(cy - 100 * scale)})`),
|
||
blueBelow: await json(`__gm.pixelAt(${cx}, ${Math.round(cy + 100 * scale)})`),
|
||
pixels: await json('__gm.cameraShaderPixels()'),
|
||
};
|
||
};
|
||
const same = (a, b) => JSON.stringify([a.centre, a.above]) === JSON.stringify([b.centre, b.above]);
|
||
const unshaded = await rows();
|
||
await wear(PASS);
|
||
const handedThrough = await rows();
|
||
const differing = handedThrough.above.filter((hex, i) => hex !== unshaded.above[i]).length;
|
||
check(
|
||
'a camera shader that changes nothing draws the game picture byte for byte, the right way up',
|
||
unshaded.pixels === null &&
|
||
handedThrough.pixels !== null &&
|
||
is(unshaded.blueAbove, [0x33, 0x66, 0xff]) &&
|
||
!is(unshaded.blueBelow, [0x33, 0x66, 0xff]) &&
|
||
same(handedThrough, unshaded),
|
||
JSON.stringify({ differing, pixels: handedThrough.pixels, blue: handedThrough.blueAbove }),
|
||
);
|
||
|
||
// 63f. A flat red fills the main camera's whole picture, corners included; the overlay camera's
|
||
// UI square lies on top of it in its own green (the proof moved here from #302).
|
||
await wear(FLAT_RED);
|
||
const { frame } = await json('__gm.gameScale()');
|
||
const corners = await json(
|
||
`__gm.pixels(${JSON.stringify([
|
||
{ x: frame.x + 2, y: frame.y + 2 },
|
||
{ x: frame.x + frame.w - 3, y: frame.y + 2 },
|
||
{ x: frame.x + 2, y: frame.y + frame.h - 3 },
|
||
{ x: frame.x + frame.w - 3, y: frame.y + frame.h - 3 },
|
||
])})`,
|
||
);
|
||
const reddened = await picture();
|
||
await shot('63-camera-shader-red');
|
||
check(
|
||
'a flat red fills the whole game picture, and the overlay camera’s UI keeps its colour',
|
||
corners.every((hex) => is(hex, RED)) && is(reddened.left, RED) && is(reddened.centre, GREEN),
|
||
JSON.stringify({ corners, reddened }),
|
||
);
|
||
|
||
// 63g. `Texture size` is the camera's raster in either resolution – a pixel counts as a camera
|
||
// pixel (the user's choice, #299) – while Screen pixels draws the picture with as many pixels
|
||
// as it covers on screen. Fitted, so that is not 640 by chance.
|
||
await mutate(`__gm.state.setGameScale('fit')`);
|
||
const measured = {};
|
||
for (const resolution of ['screen', 'camera']) {
|
||
await wear(SIZE, resolution);
|
||
measured[resolution] = {
|
||
colour: (await picture()).left,
|
||
pixels: await json('__gm.cameraShaderPixels()'),
|
||
};
|
||
}
|
||
const fitted = await json('__gm.gameScale()');
|
||
const ratio = (await json('__gm.pixelGrid()')).resolution;
|
||
// Tight: counted in screen pixels the fitted 670 would come out as 171 / 96, close to the real thing.
|
||
const exactly = (hex, rgb) => channels(hex).every((v, i) => Math.abs(v - rgb[i]) <= 2);
|
||
check(
|
||
'Texture size is View width × View height in both resolutions, and only Camera pixels draws 640 × 360',
|
||
['screen', 'camera'].every((r) => exactly(measured[r].colour, [163, 92, 0])) &&
|
||
measured.camera.pixels?.w === 640 &&
|
||
measured.camera.pixels?.h === 360 &&
|
||
measured.screen.pixels?.w === Math.round(fitted.frame.w * ratio) &&
|
||
measured.screen.pixels?.w !== 640,
|
||
JSON.stringify({ measured, frame: fitted.frame, ratio }),
|
||
);
|
||
|
||
// 63h. Taken off again, the camera draws as before and lays no picture over its view.
|
||
await mutate(`__gm.state.setGameScale(1)`);
|
||
await wear(null);
|
||
const back = await rows();
|
||
check(
|
||
'a camera without a shader draws as it did before',
|
||
back.pixels === null && same(back, unshaded),
|
||
JSON.stringify({ pixels: back.pixels }),
|
||
);
|
||
|
||
// 63i. The scene view lays the shader over the main camera's frame only while the eye's Shader
|
||
// switch is on – a real click on it – and never past the frame.
|
||
await wear(FLAT_RED);
|
||
await showTab('scene');
|
||
await evaluate(`(() => {
|
||
const main = __gm.state.activeScene.objects.find((o) => o.id === 'o3');
|
||
__gm.lookAt(main.position.x, main.position.y);
|
||
})()`);
|
||
await sleep(150);
|
||
const sceneSpots = async () => {
|
||
const r = await json('__gm.objectRect("o3")');
|
||
const [inside, outside] = await json(
|
||
`__gm.pixels(${JSON.stringify([
|
||
{ x: Math.round(r.x - r.w * 0.3), y: Math.round(r.y - r.h * 0.3) },
|
||
{ x: Math.round(r.x - r.w * 0.7), y: Math.round(r.y - r.h * 0.3) },
|
||
])})`,
|
||
);
|
||
return { inside, outside, pixels: await json(`__gm.cameraShaderPixels('scene')`) };
|
||
};
|
||
const switchedOff = await sceneSpots();
|
||
const eye = await json(`__gm.sceneHeaderAt('visibility')`);
|
||
await t.click(eye.x, eye.y);
|
||
const shaderSwitch = (await json('__gm.visibilityPopup()')).find(
|
||
(e) => e.type === 'camera' && e.part === 'shader',
|
||
);
|
||
await t.click(shaderSwitch.x, shaderSwitch.y);
|
||
await press('Escape');
|
||
await sleep(150);
|
||
const switchedOn = await sceneSpots();
|
||
await shot('63-camera-shader-scene');
|
||
check(
|
||
'the scene view shows the camera shader over the frame only while the eye switch is on',
|
||
shaderSwitch.on === false &&
|
||
switchedOff.pixels === null &&
|
||
!is(switchedOff.inside, RED) &&
|
||
switchedOn.pixels !== null &&
|
||
is(switchedOn.inside, RED) &&
|
||
!is(switchedOn.outside, RED),
|
||
JSON.stringify({ switchedOff, switchedOn, shaderSwitch }),
|
||
);
|
||
|
||
// 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;
|
||
p.assets = p.assets.filter((a) => !a.id.startsWith('rl63-'));
|
||
__gm.state.setSceneVisibility('camera', 'shader', false);
|
||
__gm.state.setGameScale(${JSON.stringify(scaleBefore)});
|
||
`);
|
||
}
|