Lay a shader over a camera's whole picture (#299)
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 14m59s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 16:03:09 +02:00
1 parent b2326085ed
commit 2e55071fa5
19 files changed
+773 -31

No files matched your search

+205
View File
@@ -4,6 +4,8 @@
* 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.
*/
@@ -134,6 +136,206 @@ export async function renderLayers(t) {
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(`
@@ -142,5 +344,8 @@ export async function renderLayers(t) {
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)});
`);
}