/** * 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. * `Is hovered` and `Mouse position` find the mouse through the overlay camera too (#304), and a * graph moves a sprite onto another layer while the game runs (#305). * 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(); // 63c2. (#304) The mouse through the overlay camera, zoomed 2x: 30 units right of the centre of // the picture it lies 15 units right of the overlay's centre, on the 40 wide square B. Through // the main camera nothing is there. Logged each tick: hovered through it, without it, its x. const node = (id, type, x, params = {}) => ({ id, type, x, y: 0, params }); const edge = (id, from, fromPort, to, toPort) => ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort }, }); const hoverGraph = { nodes: [ node('rl63-tick', 'OnUpdate', 0), node('rl63-b-find', 'ObjectByName', 0, { name: 'B 63' }), node('rl63-cam-find', 'ObjectByName', 0, { name: 'Overlay 63' }), node('rl63-through', 'IsHovered', 200), node('rl63-plain', 'IsHovered', 200), node('rl63-mouse', 'MousePosition', 200), node('rl63-log1', 'Log', 400), node('rl63-log2', 'Log', 600), node('rl63-log3', 'Log', 800), ], edges: [ edge('rl63-e1', 'rl63-tick', 'exec', 'rl63-log1', 'exec'), edge('rl63-e2', 'rl63-log1', 'exec', 'rl63-log2', 'exec'), edge('rl63-e3', 'rl63-log2', 'exec', 'rl63-log3', 'exec'), edge('rl63-e4', 'rl63-b-find', 'object', 'rl63-through', 'object'), edge('rl63-e5', 'rl63-cam-find', 'object', 'rl63-through', 'camera'), edge('rl63-e6', 'rl63-b-find', 'object', 'rl63-plain', 'object'), edge('rl63-e7', 'rl63-cam-find', 'object', 'rl63-mouse', 'camera'), edge('rl63-e8', 'rl63-through', 'result', 'rl63-log1', 'message'), edge('rl63-e9', 'rl63-plain', 'result', 'rl63-log2', 'message'), edge('rl63-e10', 'rl63-mouse', 'x', 'rl63-log3', 'message'), ], }; await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; p.assets.push({ id: 'rl63-hover', type: 'behavior', name: 'Hover 63', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(hoverGraph)} }); ${MAKE_OBJECT} scene.objects.push(make('rl63-logic', 'rl63-hover', '', 0, 'Hover 63')); const cam = scene.objects.find((o) => o.id === '${OVERLAY}'); window.__rl63.overlay = structuredClone(cam.overrides); cam.overrides = { ...cam.overrides, zoom: 2 }; `); await play(); await sleep(300); const game = await json('__gm.gameScale()'); const centre = { x: game.frame.x + game.frame.w / 2, y: game.frame.y + game.frame.h / 2 }; await t.mouse('mouseMoved', centre.x + 30 * game.scale, centre.y); await sleep(300); const ran = await json('__gm.world()'); const hovered = ran?.log.slice(-3) ?? []; const overlayX = ran?.entities.find((e) => e.objectId === OVERLAY)?.worldX; await stop(); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => o.id !== 'rl63-logic'); p.assets = p.assets.filter((a) => a.id !== 'rl63-hover'); scene.objects.find((o) => o.id === '${OVERLAY}').overrides = window.__rl63.overlay; `); check( 'Is hovered through the overlay camera finds what only it shows, through the main one not', hovered[0] === 'true' && hovered[1] === 'false' && Math.abs(Number(hovered[2]) - (overlayX + 15)) < 1, JSON.stringify({ hovered, overlayX }), ); // 63c3. (#305) A graph moves the red square A onto `UI` with a Properties node once the game runs: // the main camera no longer draws it, and `Render layer of` names its new layer. const switchGraph = { nodes: [ node('rl63-start', 'OnStart', 0), node('rl63-a-find', 'ObjectByName', 0, { name: 'A 63' }), { ...node('rl63-set', 'Properties', 200, { objectType: 'sprite', renderLayer: 1 }), ports: [{ key: 'renderLayer', kind: 'number', write: true }], }, node('rl63-of', 'RenderLayerOf', 200), node('rl63-name', 'Log', 400), node('rl63-number', 'Log', 600), ], edges: [ edge('rl63-s1', 'rl63-start', 'exec', 'rl63-set', 'exec'), edge('rl63-s2', 'rl63-set', 'exec', 'rl63-name', 'exec'), edge('rl63-s3', 'rl63-name', 'exec', 'rl63-number', 'exec'), edge('rl63-s4', 'rl63-a-find', 'object', 'rl63-set', 'object'), edge('rl63-s5', 'rl63-a-find', 'object', 'rl63-of', 'object'), edge('rl63-s6', 'rl63-of', 'name', 'rl63-name', 'message'), edge('rl63-s7', 'rl63-of', 'number', 'rl63-number', 'message'), ], }; await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; p.assets.push({ id: 'rl63-switch', type: 'behavior', name: 'Switch 63', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(switchGraph)} }); ${MAKE_OBJECT} scene.objects.push(make('rl63-switcher', 'rl63-switch', '', 0, 'Switch 63')); `); await play(); await sleep(400); const switched = await picture(); const named = (await json('__gm.world()'))?.log.slice(-2) ?? []; await stop(); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => o.id !== 'rl63-switcher'); p.assets = p.assets.filter((a) => a.id !== 'rl63-switch'); `); check( 'a graph moves a sprite onto UI while the game runs, and Render layer of names it', !switched.main.includes('rl63-a') && !is(switched.right, RED) && JSON.stringify(named) === '["UI","1"]', JSON.stringify({ switched, named }), ); // 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)}); `); }