diff --git a/README.md b/README.md index 6473184c..f2521cd4 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1720 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1725 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 3a4d5813..0572e141 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -386,7 +386,7 @@ export function animatableDefsOf( const declared = asset.type === 'behavior' ? asset.variables - : asset.type === 'sprite' || asset.type === 'text' + : asset.type === 'sprite' || asset.type === 'text' || asset.type === 'camera' ? shaderParamRows(project, shaderOf(bag)) : []; const taken = new Set(own.map((d) => d.key)); diff --git a/src/core/model/sceneVisibility.ts b/src/core/model/sceneVisibility.ts index 4cba77ac..3f1abcc8 100644 --- a/src/core/model/sceneVisibility.ts +++ b/src/core/model/sceneVisibility.ts @@ -5,7 +5,7 @@ */ import { hasSceneIcon, type PlaceableAssetType } from './AssetTypes'; -export type VisibilityPart = 'symbol' | 'label' | 'shape'; +export type VisibilityPart = 'symbol' | 'label' | 'shape' | 'shader'; export type TypeVisibility = Record; @@ -29,17 +29,22 @@ export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [ /** * The switches a type has. Only a type with a symbol can hide it, every type can show its name, * and only what collides has an outline to hide: a collider its shape, a tilemap the outline of - * its solid cells – the map's art stays. A sprite without its picture would be nothing at all. + * its solid cells – the map's art stays. A sprite without its picture would be nothing at all. Only + * a camera has a shader to lay over its frame (#299). */ export function visibilityParts(type: PlaceableAssetType): VisibilityPart[] { const parts: VisibilityPart[] = hasSceneIcon(type) ? ['symbol', 'label'] : ['label']; if (type === 'collider' || type === 'tilemap' || type === 'clip') parts.push('shape'); + if (type === 'camera') parts.push('shader'); return parts; } -/** What a type shows until it is switched: all of it, except a name nothing showed before. */ +/** + * What a type shows until it is switched: all of it, except a name nothing showed before and a + * camera's shader. + */ function defaultVisibility(type: PlaceableAssetType): TypeVisibility { - return { symbol: true, label: hasSceneIcon(type), shape: true }; + return { symbol: true, label: hasSceneIcon(type), shape: true, shader: false }; } export function defaultSceneVisibility(): SceneVisibility { diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index cf4a5db8..b0ada01f 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -102,6 +102,7 @@ import { TIME_SCALE_KEY, } from './particles'; import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders'; +import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader'; import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds'; import { Theme } from '../../ui'; @@ -454,6 +455,16 @@ const ASSET_SCHEMAS: Record = { { key: SEES_KEY, label: 'Sees', kind: 'layers', default: ALL_LAYERS, renderLayers: true }, // Off: a candidate for the main camera (#5). On: drawn over the main one, see-through (#302). { key: OVERLAY_KEY, label: 'Overlay', kind: 'boolean', default: false }, + // Laid over the camera's whole picture (#299); its parameters follow, as on a sprite. + { key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] }, + { + key: SHADER_RESOLUTION_KEY, + label: 'Shader resolution', + kind: 'enum', + default: 'screen', + options: SHADER_RESOLUTION_OPTIONS, + shownWhenSet: SHADER_KEY, + }, ], sprite: [ { key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 }, @@ -982,10 +993,10 @@ function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): Propert shaderParamRows(project, shaderOf(bag)), shaderParamRows(project, fieldShaderOf(bag)), ); - if (asset.type === 'text') + if (asset.type === 'text' || asset.type === 'camera') return [ - ...FULL_SCHEMAS.text, - ...rowsNotClaimedBy(shaderParamRows(project, shaderOf(bag)), FULL_SCHEMAS.text), + ...FULL_SCHEMAS[asset.type], + ...rowsNotClaimedBy(shaderParamRows(project, shaderOf(bag)), FULL_SCHEMAS[asset.type]), ]; return FULL_SCHEMAS[asset.type]; } diff --git a/src/core/properties/cameraShader.ts b/src/core/properties/cameraShader.ts new file mode 100644 index 00000000..437cd281 --- /dev/null +++ b/src/core/properties/cameraShader.ts @@ -0,0 +1,18 @@ +import { type PropertyBag, textOf } from '../model/Properties'; + +/** + * How many pixels a camera's shader draws its picture with (#299): as many as the picture covers on + * screen, sharp at any game scale, or the camera's own raster, scaled up blocky for pixel art. + */ +export const SHADER_RESOLUTION_KEY = 'shaderResolution'; + +export type ShaderResolution = 'screen' | 'camera'; + +export const SHADER_RESOLUTION_OPTIONS: { value: ShaderResolution; label: string }[] = [ + { value: 'screen', label: 'Screen pixels' }, + { value: 'camera', label: 'Camera pixels' }, +]; + +export function shaderResolutionOf(props: PropertyBag): ShaderResolution { + return textOf(props[SHADER_RESOLUTION_KEY] ?? '') === 'camera' ? 'camera' : 'screen'; +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 6f5b4551..41f6b544 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -116,11 +116,12 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { const shaderGraphs = new Map(); const shaderRows = new Map(); const particleGraphs = new Map(); - const wearers = new Map(); + const wearers = new Map(); for (const a of rawAssets) { if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue; rawProps.set(a.id, asRecord(a.props)); - if (a.type === 'sprite' || a.type === 'particles' || a.type === 'text') wearers.set(a.id, a.type); + if (a.type === 'sprite' || a.type === 'particles' || a.type === 'text' || a.type === 'camera') + wearers.set(a.id, a.type); if (a.type === 'behavior' || a.type === 'variables' || a.type === 'struct') { const vars = normalizeVariables(a.variables); variables.set(a.id, vars); @@ -143,8 +144,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { } } /** - * A sprite's, source's or text's rows end with the parameters of the shader its bag names – an - * override's, else the asset's. Only a sprite's rows yield to none: a name the others have stays theirs. + * A sprite's, source's, text's or camera's rows end with the parameters of the shader its bag names – + * an override's, else the asset's. Only a sprite's rows yield to none: a name the others have stays + * theirs. */ const schemaOf = (assetId: string, bag: Record): PropertySchema | undefined => { const schema = schemas.get(assetId); diff --git a/src/editor/debug/renderHooks.ts b/src/editor/debug/renderHooks.ts index 43210026..b5676dd2 100644 --- a/src/editor/debug/renderHooks.ts +++ b/src/editor/debug/renderHooks.ts @@ -194,6 +194,9 @@ export function renderHooks({ app, state, editor }: HookContext) { /** The device pixels of a source's field-shader picture, null while none is drawn (#170). */ fieldPixels: (objectId: string, where: 'scene' | 'game' = 'scene') => (where === 'game' ? gameView : viewport).fieldPixels(objectId), + /** The pixels of the camera shader's picture, null while none is laid over the view (#299). */ + cameraShaderPixels: (where: 'scene' | 'game' = 'game') => + (where === 'game' ? gameView : viewport).cameraShaderPixels(), /** Graphs compiled so far; a moved node must not add one (#65). */ shaderCompiles: () => shaderLibrary.compiles, /** Which views redraw every tick because a Time shader is on screen. */ diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index a7082c0a..1f6644ba 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -96,10 +96,11 @@ export function buildPropertyPortSection( } if (target !== 'behavior') { const owner = `a ${targetLabel(target)}`; - // A sprite's or text's rows go on with the parameters of the shader it wears – any shader, the node - // cannot know which, so each entry says whose it is. Keyed by the def itself: a shader parameter and - // a property of the type may share a name, and only the shader's entry earns the note. - const shaderRows = target === 'sprite' || target === 'text' ? allShaderParamRows(host.state.project) : []; + // A sprite's, text's or camera's rows go on with the parameters of the shader it wears – any shader, + // the node cannot know which, so each entry says whose it is. Keyed by the def itself: a shader + // parameter and a property of the type may share a name, and only the shader's entry earns the note. + const wears = target === 'sprite' || target === 'text' || target === 'camera'; + const shaderRows = wears ? allShaderParamRows(host.state.project) : []; const from = new Map(shaderRows.map((row) => [row.def, row.shaders.join(', ')])); buildPickedPortSection(host, assetId, node, { noun: 'Property', diff --git a/src/editor/panels/SceneVisibilityPopup.ts b/src/editor/panels/SceneVisibilityPopup.ts index f8f64b0d..25e1c960 100644 --- a/src/editor/panels/SceneVisibilityPopup.ts +++ b/src/editor/panels/SceneVisibilityPopup.ts @@ -5,8 +5,15 @@ import type { EditorState } from '../state'; import { ASSET_ICON_SVG } from './assetIcons'; import { VISIBILITY_ICON_SVG } from './toolbarIcons'; -const PARTS: readonly VisibilityPart[] = ['symbol', 'label', 'shape']; -const PART_CAPTIONS: Record = { symbol: 'Symbol', label: 'Name', shape: 'Shape' }; +const PARTS: readonly VisibilityPart[] = ['symbol', 'label', 'shape', 'shader']; +const PART_CAPTIONS: Record = { + symbol: 'Symbol', + label: 'Name', + shape: 'Shape', + shader: 'Shader', +}; +/** Switches only a few types have: the others leave the column empty rather than greyed out. */ +const RARE_PARTS: ReadonlySet = new Set(['shape', 'shader']); /** One column per switch, wide enough for its caption. */ const COLUMN = 44; const NAME_WIDTH = 90; @@ -20,9 +27,9 @@ function gap(w: number): Spacer { /** * The eye in the scene tab's header: every type that can be placed, and per type a switch for its symbol, its - * name and – a collider only – its outline in the scene view. A switch a type does not have is - * greyed out, or missing where no other type has it either. Clicks keep it open: one usually - * switches several. + * name, – a collider only – its outline in the scene view and – a camera only – its shader (#299). A + * switch a type does not have is greyed out, or missing where no other type has it either. Clicks keep + * it open: one usually switches several. */ export class SceneVisibilityPopup extends Popup { private readonly switches = new Map(); @@ -45,7 +52,7 @@ export class SceneVisibilityPopup extends Popup { row.add(new Label(ASSET_TYPE_LABELS[type], { width: NAME_WIDTH })); const parts = visibilityParts(type); for (const part of PARTS) { - if (part === 'shape' && !parts.includes(part)) { + if (RARE_PARTS.has(part) && !parts.includes(part)) { row.add(gap(COLUMN)); continue; } diff --git a/src/editor/panels/toolbarIcons.ts b/src/editor/panels/toolbarIcons.ts index 8aa35265..ad423d47 100644 --- a/src/editor/panels/toolbarIcons.ts +++ b/src/editor/panels/toolbarIcons.ts @@ -8,6 +8,7 @@ import menu from './icons/menu.svg?raw'; import pause from './icons/pause.svg?raw'; import play from './icons/play.svg?raw'; import redo from './icons/redo.svg?raw'; +import shader from './icons/shader.svg?raw'; import showLabel from './icons/show-label.svg?raw'; import showShape from './icons/show-shape.svg?raw'; import showSymbol from './icons/show-symbol.svg?raw'; @@ -16,9 +17,10 @@ import undo from './icons/undo.svg?raw'; export const TOOLBAR_ICON_SVG = { menu, undo, redo, eye, play, pause, stop }; -/** The switches behind the eye: a type's symbol, its name, a collider's outline. */ +/** The switches behind the eye: a type's symbol, its name, a collider's outline, a camera's shader. */ export const VISIBILITY_ICON_SVG: Record = { symbol: showSymbol, label: showLabel, shape: showShape, + shader, }; diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 65f9d68d..068a4bff 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -54,7 +54,7 @@ import { drawSymbolIcon, SYMBOL_GRID, SYMBOL_OUTLINE, type SymbolIcon } from '.. import { type JointLink, jointLinkOf } from '../../viewport/jointLinks'; import { type LabelBox, stackLabels, type WantedLabel } from '../../viewport/labelRows'; import { layoutSymbols, type SymbolSpot } from '../../viewport/symbolLayout'; -import { cameraOfItems } from '../../viewport/activeCamera'; +import { type ActiveCamera, cameraOfItems } from '../../viewport/activeCamera'; import { freeViewOfCamera, screenToWorld, type View, worldToScreen } from '../../viewport/view'; import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World'; import { FreeViewport } from './FreeViewport'; @@ -176,9 +176,9 @@ export class SceneViewport extends FreeViewport { constructor(state: EditorState) { super(state); - // Grid behind the content, links below the symbols, gizmos above everything clipped. + // Grid behind the content, links below the objects, gizmos above everything clipped. this.addChildAt(this.grid, 1); - this.world.addChildAt(this.links, 1); + this.world.addChildAt(this.links, 0); this.content.addChild(this.gizmos); this.links.eventMode = 'none'; this.labelLayer.eventMode = 'none'; @@ -278,6 +278,15 @@ export class SceneViewport extends FreeViewport { return Boolean(this.holder()); } + /** The main camera's shader over its frame, while the eye's switch says so (#299); off by default. */ + protected override shadingCamera(_main: ActiveCamera | null, items: DrawItem[]): ActiveCamera | null { + return shows(this.state.project.sceneVisibility, 'camera', 'shader') ? cameraOfItems(items) : null; + } + + protected override keepsWorldAroundShader(): boolean { + return true; + } + protected override get itemsInteractive(): boolean { return true; } diff --git a/src/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts index 441fd199..d3f8112b 100644 --- a/src/viewport/WorldViewport.ts +++ b/src/viewport/WorldViewport.ts @@ -23,6 +23,7 @@ import { textureLibrary } from './TextureLibrary'; import { gameClock } from '../core/time'; import { overlayView, type View, worldBounds, worldToScreen } from './view'; import type { ActiveCamera } from './activeCamera'; +import { CameraShade } from './cameraShade'; import { cameraSees } from '../core/model/renderLayers'; /** How strongly the cut-off part of an object shows while the scene view opens its clip area (#260). */ @@ -46,6 +47,11 @@ export abstract class WorldViewport extends W protected readonly bg = new Graphics(); protected readonly content = new Container(); protected readonly world = new Container(); + /** Masks and objects: what a camera's shader is laid over (#299), drawn into its picture first. */ + private readonly shaded = new Container(); + /** Where `shaded` waits while the shader's picture stands in for it: on no stage at all. */ + private readonly offstage = new Container(); + private cameraShade: CameraShade | null = null; /** Ordered by `z`, ties by hierarchy order (see `syncDrawOrder`). */ protected readonly objects = new Container(); /** Editor symbols, always above `objects` so no sprite can hide them whatever the z-order. */ @@ -92,7 +98,8 @@ export abstract class WorldViewport extends W protected constructor(protected readonly state: S) { super(); - this.world.addChild(this.masks, this.objects, this.overlay); + this.shaded.addChild(this.masks, this.objects); + this.world.addChild(this.shaded, this.overlay); this.content.addChild(this.world); this.addChild(this.bg, this.content, this.contentMask); this.content.mask = this.contentMask; @@ -116,6 +123,8 @@ export abstract class WorldViewport extends W override destroy(options?: Parameters[0]): void { for (const stop of this.stopFiles) stop(); for (const overlay of this.overlays.values()) overlay.destroy(); + this.unshadeCamera(); + this.offstage.destroy(); super.destroy(options); } @@ -135,6 +144,16 @@ export abstract class WorldViewport extends W return []; } + /** The camera whose shader is laid over this view (#299): the one the view is through. */ + protected shadingCamera(main: ActiveCamera | null, _items: DrawItem[]): ActiveCamera | null { + return main; + } + + /** Whether the world stays drawn around the shaded frame: the scene view shows more than the camera. */ + protected keepsWorldAroundShader(): boolean { + return false; + } + /** Only called when `currentView()` is non-null. */ protected abstract collectFrame(): Frame; @@ -324,6 +343,7 @@ export abstract class WorldViewport extends W this.animated = false; this.drawn = null; if (!view) { + this.unshadeCamera(); this.syncOverlays(null, null, null); return; } @@ -405,9 +425,52 @@ export abstract class WorldViewport extends W } this.syncDrawOrder(items); this.sweepMasks(); + this.shadeCamera(this.shadingCamera(camera, items), view, shaders, background); this.syncOverlays(frame, view, camera); } + /** + * Lays the camera's shader over its frame (#299): the masks and objects are drawn into the + * camera's picture, cleared to the scene's background – see-through on a view that paints none – + * and the picture stands in for them. Without a shader that compiles they are drawn as they are. + */ + private shadeCamera( + camera: ActiveCamera | null, + view: View, + shaders: ShaderSource, + background: number, + ): void { + const renderer = ui().app.renderer; + const shade = this.cameraShade ?? new CameraShade(); + const clear = this.paintsBackground() ? background : null; + const ppu = view.scale * renderer.resolution; + if (!camera || !shade.draw(this.shaded, camera, shaders, ppu, renderer, clear)) { + if (shade !== this.cameraShade) shade.destroy(); + this.unshadeCamera(); + return; + } + this.cameraShade = shade; + if (!this.keepsWorldAroundShader() && this.shaded.parent !== this.offstage) + this.offstage.addChild(this.shaded); + if (shade.mesh.parent !== this.world) + this.world.addChildAt(shade.mesh, this.world.getChildIndex(this.overlay)); + if (wornShader(camera.props, shaders)?.time) this.animated = true; + } + + /** Back to drawing the objects themselves. */ + private unshadeCamera(): void { + if (!this.cameraShade) return; + this.cameraShade.destroy(); + this.cameraShade = null; + if (this.shaded.parent !== this.world) + this.world.addChildAt(this.shaded, this.world.getChildIndex(this.overlay)); + } + + /** The pixels of the camera shader's picture while one is drawn (#299, test hook). */ + cameraShaderPixels(): { w: number; h: number } | null { + return this.cameraShade?.pixels ?? null; + } + /** * Lays one view per overlay camera over this one (#302): each draws what its camera sees of the * same frame, see-through, fitted into the main picture. Without a main view there are none. diff --git a/src/viewport/cameraPicture.ts b/src/viewport/cameraPicture.ts new file mode 100644 index 00000000..d133d84d --- /dev/null +++ b/src/viewport/cameraPicture.ts @@ -0,0 +1,54 @@ +/** + * The picture a camera's shader is laid over (#299), as numbers: how many pixels it gets, which + * transform takes the world into it, and what a pixel of it counts as. Pure, so the arithmetic is + * tested without a GPU. + */ +import type { Mat } from '../core/model/matrix'; +import type { PropertyBag } from '../core/model/Properties'; +import { shaderResolutionOf } from '../core/properties/cameraShader'; +import { cameraFrame, cameraSize } from '../core/properties/camera'; + +/** The longest side the picture may have, a game tab on a large screen included. */ +export const CAMERA_MAX_PIXELS = 4096; + +export interface CameraPicture { + /** The picture's size in pixels: the camera's raster, or the frame as large as it stands on screen. */ + pixels: { w: number; h: number }; + /** World units to pixels of the picture: the frame's top left corner is 0,0. */ + toPicture: Mat; + /** The frame in world units, centred on the camera: where the shaded picture is drawn. */ + frame: { w: number; h: number }; + /** + * Camera pixels one pixel of the picture covers, per axis: what the shader counts a pixel as in + * either resolution (the user's choice), so a Blur of 4 is 4 camera pixels at any game scale. + */ + pixelScale: { x: number; y: number }; + /** Whether the picture is scaled up without smoothing: the camera's raster, for pixel art. */ + blocky: boolean; +} + +/** `devicePixelsPerUnit` is what one world unit covers on screen, in device pixels. */ +export function cameraPicture( + camera: { x: number; y: number; props: PropertyBag }, + devicePixelsPerUnit: number, +): CameraPicture { + const frame = cameraFrame(camera.props); + const size = cameraSize(camera.props); + const blocky = shaderResolutionOf(camera.props) === 'camera'; + const pixels = blocky + ? { w: pixelsAlong(size.w), h: pixelsAlong(size.h) } + : { w: pixelsAlong(frame.w * devicePixelsPerUnit), h: pixelsAlong(frame.h * devicePixelsPerUnit) }; + const a = pixels.w / frame.w; + const d = pixels.h / frame.h; + return { + pixels, + toPicture: { a, b: 0, c: 0, d, tx: pixels.w / 2 - camera.x * a, ty: pixels.h / 2 - camera.y * d }, + frame, + pixelScale: { x: size.w / pixels.w, y: size.h / pixels.h }, + blocky, + }; +} + +function pixelsAlong(length: number): number { + return Math.min(CAMERA_MAX_PIXELS, Math.max(1, Math.round(Number.isFinite(length) ? length : 1))); +} diff --git a/src/viewport/cameraShade.ts b/src/viewport/cameraShade.ts new file mode 100644 index 00000000..5c6a3acf --- /dev/null +++ b/src/viewport/cameraShade.ts @@ -0,0 +1,79 @@ +import { type Container, Matrix, type Mesh, RenderTexture, type Renderer } from 'pixi.js'; +import { shaderOf } from '../core/properties/shaders'; +import type { ActiveCamera } from './activeCamera'; +import { cameraPicture } from './cameraPicture'; +import { type ShaderSource, syncShader } from './shaders'; +import { disposeSpriteMesh, newSpriteMesh, placeNode } from './symbols'; +import { ADDRESS_MODE } from './TextureLibrary'; + +/** + * A camera's shader (#299): the world as the camera sees it is drawn into a picture of its frame, and + * the shader draws that picture where the frame stands. `mesh` is what a view attaches, in world units. + */ +export class CameraShade { + readonly mesh: Mesh = newSpriteMesh(); + private picture: RenderTexture | null = null; + + /** + * Draws `world`, which is in world units, into the camera's picture and lays the shader over it. + * `clearColor` is the scene's background, or null for a see-through overlay camera. False – and + * nothing drawn – while the camera wears no shader that compiles. + */ + draw( + world: Container, + camera: ActiveCamera, + shaders: ShaderSource, + devicePixelsPerUnit: number, + renderer: Renderer, + clearColor: number | null, + ): boolean { + const id = shaderOf(camera.props); + const compiled = id ? shaders.compiled(id) : null; + if (!compiled) return false; + const shape = cameraPicture(camera, devicePixelsPerUnit); + const previous = this.picture; + const fits = previous && previous.width === shape.pixels.w && previous.height === shape.pixels.h; + const picture = fits ? previous : RenderTexture.create({ width: shape.pixels.w, height: shape.pixels.h }); + const t = shape.toPicture; + renderer.render({ + container: world, + target: picture, + clear: true, + clearColor: clearColor === null ? [0, 0, 0, 0] : clearColor, + transform: new Matrix(t.a, t.b, t.c, t.d, t.tx, t.ty), + }); + // The picture is this camera's alone, so filter and edge are set on it rather than on a variant. + const style = picture.source.style; + const addressMode = ADDRESS_MODE[compiled.edge]; + const scaleMode = shape.blocky ? 'nearest' : 'linear'; + if (style.addressMode !== addressMode || style.scaleMode !== scaleMode) { + style.addressMode = addressMode; + style.scaleMode = scaleMode; + style.update(); + } + placeNode( + this.mesh, + { a: 1, b: 0, c: 0, d: 1, tx: camera.x, ty: camera.y }, + shape.frame.w, + shape.frame.h, + ); + this.mesh.texture = picture; + // The frame is the edge, so neither Padding nor Vertex output moves it; a pixel counts as a camera pixel. + syncShader(this.mesh, camera.props, shaders, null, id, shape.pixelScale); + this.picture = picture; + // Only now: the shader kept the old picture bound until `syncShader` handed it the new one. + if (previous && previous !== picture) previous.destroy(true); + return true; + } + + /** The picture's size in pixels while one is drawn (test hook). */ + get pixels(): { w: number; h: number } | null { + return this.picture ? { w: this.picture.width, h: this.picture.height } : null; + } + + destroy(): void { + disposeSpriteMesh(this.mesh); + this.picture?.destroy(true); + this.picture = null; + } +} diff --git a/tests/editor/scene-visibility.test.ts b/tests/editor/scene-visibility.test.ts index df22dc29..4daa5339 100644 --- a/tests/editor/scene-visibility.test.ts +++ b/tests/editor/scene-visibility.test.ts @@ -44,6 +44,17 @@ describe('scene visibility', () => { expect(visibilityParts('tilemap')).toEqual(['label', 'shape']); }); + it('gives only a camera a switch for its shader, off until switched on (#299)', () => { + expect(visibilityParts('camera')).toEqual(['symbol', 'label', 'shader']); + expect(VISIBILITY_TYPES.filter((type) => visibilityParts(type).includes('shader'))).toEqual(['camera']); + const visibility = defaultSceneVisibility(); + expect(shows(visibility, 'camera', 'shader')).toBe(false); + visibility.camera.shader = true; + expect(shows(visibility, 'camera', 'shader')).toBe(true); + visibility.sprite.shader = true; + expect(shows(visibility, 'sprite', 'shader')).toBe(false); + }); + it('shows what it always showed in a new project and in one written before there was any', () => { for (const visibility of [ createEmptyProject('p', 'P').sceneVisibility, @@ -64,7 +75,9 @@ describe('scene visibility', () => { project.sceneVisibility.tilemap.shape = false; project.sceneVisibility.rigidbody.label = false; project.sceneVisibility.sprite.label = true; + project.sceneVisibility.camera.shader = true; const back = deserializeProject(JSON.parse(JSON.stringify(serializeProject(project)))).sceneVisibility; + expect(back.camera.shader).toBe(true); expect(back.collider.shape).toBe(false); expect(back.tilemap.shape).toBe(false); expect(back.rigidbody.label).toBe(false); @@ -76,7 +89,7 @@ describe('scene visibility', () => { const read = sceneVisibilityOf({ sceneVisibility: { camera: { symbol: false, label: 'no' }, texture: { label: true }, text: 3 }, }); - expect(read.camera).toEqual({ symbol: false, label: true, shape: true }); + expect(read.camera).toEqual({ symbol: false, label: true, shape: true, shader: false }); expect(read.text).toEqual(defaultSceneVisibility().text); expect('texture' in read).toBe(false); expect(sceneVisibilityOf(null)).toEqual(defaultSceneVisibility()); diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index d8948854..770380e3 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -113,8 +113,8 @@ describe('icon files', () => { }); it('has one picture per switch behind the eye', () => { - expect(Object.keys(VISIBILITY_ICON_SVG).sort()).toEqual(['label', 'shape', 'symbol']); - expect(new Set(Object.values(VISIBILITY_ICON_SVG)).size).toBe(3); + expect(Object.keys(VISIBILITY_ICON_SVG).sort()).toEqual(['label', 'shader', 'shape', 'symbol']); + expect(new Set(Object.values(VISIBILITY_ICON_SVG)).size).toBe(4); }); // The bar carries no captions any more, so a picture shared with a tab or an asset type would be diff --git a/tests/viewport/camera-shader.test.ts b/tests/viewport/camera-shader.test.ts new file mode 100644 index 00000000..598e693e --- /dev/null +++ b/tests/viewport/camera-shader.test.ts @@ -0,0 +1,106 @@ +/** + * A camera wears a shader over its whole picture (#299): the rows, and the picture it is laid over – + * the camera's raster or the screen's pixels, with a pixel counted as a camera pixel in both. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand } from '../../src/core/commands/graphCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { animatableDefsOf } from '../../src/core/model/animation'; +import { findAssetOf } from '../../src/core/model/Project'; +import { isRowShown, schemaForAsset, schemaForObject } from '../../src/core/properties/PropertySchema'; +import { SHADER_RESOLUTION_KEY } from '../../src/core/properties/cameraShader'; +import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders'; +import { CAMERA_MAX_PIXELS, cameraPicture } from '../../src/viewport/cameraPicture'; +import { makeSpriteScene, place, roundTrip } from '../helpers'; + +function wearing(uniform = 'fade') { + const f = makeSpriteScene(); + const camera = new CreateAssetCommand('camera', 'Cam'); + const shader = new CreateAssetCommand('shader', 'Fade'); + f.stack.execute(camera); + f.stack.execute(shader); + const declared = { name: uniform, default: 0.5, min: 0, max: 1 }; + f.stack.execute(new AddNodeCommand(shader.asset.id, SHADER_PROPERTY, 0, 0, declared)); + const cameraId = camera.asset.id; + const shaderId = shader.asset.id; + const asset = () => findAssetOf(f.project, cameraId, 'camera')!; + const rows = () => schemaForAsset(f.project, asset()); + const wear = () => f.stack.execute(new SetAssetPropertyCommand(cameraId, SHADER_KEY, shaderId)); + return { ...f, cameraId, shaderId, asset, rows, wear }; +} + +/** A pixel of the picture back in world units: the inverse of `toPicture` along each axis. */ +function worldAt(p: ReturnType, px: number, py: number) { + const t = p.toPicture; + return { x: (px - t.tx) / t.a, y: (py - t.ty) / t.d }; +} + +describe('a camera wearing a shader', () => { + it('has the picker and the resolution, the resolution shown only with a shader picked', () => { + const f = wearing(); + const resolution = f.rows().find((d) => d.key === SHADER_RESOLUTION_KEY)!; + expect(resolution).toMatchObject({ kind: 'enum', default: 'screen' }); + expect(isRowShown(resolution, f.asset().props)).toBe(false); + expect(f.rows().map((d) => d.key)).not.toContain('fade'); + f.wear(); + expect(isRowShown(resolution, f.asset().props)).toBe(true); + expect(f.rows().at(-1)).toMatchObject({ key: 'fade', kind: 'number', default: 0.5 }); + }); + + it('keeps a name it already has: `zoom` stays the camera’s own row', () => { + const f = wearing('zoom'); + f.wear(); + expect(f.rows().filter((d) => d.key === 'zoom')).toEqual([expect.objectContaining({ label: 'Zoom' })]); + }); + + it('gives a placed camera the rows of the shader it overrides, and keeps them through a save', () => { + const f = wearing(); + const id = place(f, f.cameraId, { x: 0, y: 0 }); + f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, f.shaderId)); + f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'fade', 0.25)); + expect(schemaForObject(f.project, f.asset(), f.object(id)).map((d) => d.key)).toContain('fade'); + const placed = findAssetOf(roundTrip(f.project), f.sceneId, 'scene')!.objects.find((o) => o.id === id)!; + expect(placed.overrides).toEqual({ shader: f.shaderId, fade: 0.25 }); + }); + + it('offers the shader’s parameters to an animation', () => { + const f = wearing(); + f.wear(); + expect(animatableDefsOf(f.project, f.asset()).map((d) => d.key)).toContain('fade'); + }); +}); + +describe('the picture a camera’s shader is laid over', () => { + const camera = (props: Record = {}) => ({ + x: 100, + y: -50, + props: { width: 640, height: 360, zoom: 1, shader: 'fx', ...props }, + }); + + it('has as many pixels as the frame covers on screen, and counts each as a camera pixel', () => { + const p = cameraPicture(camera(), 2); + expect(p.pixels).toEqual({ w: 1280, h: 720 }); + expect(p.pixelScale).toEqual({ x: 0.5, y: 0.5 }); + expect(p.blocky).toBe(false); + expect(worldAt(p, 0, 0)).toEqual({ x: -220, y: -230 }); + expect(worldAt(p, 1280, 720)).toEqual({ x: 420, y: 130 }); + }); + + it('has the camera’s raster with Camera pixels, at any zoom on screen', () => { + for (const ppu of [0.5, 1, 3]) { + const p = cameraPicture(camera({ [SHADER_RESOLUTION_KEY]: 'camera', zoom: 2 }), ppu); + expect(p.pixels).toEqual({ w: 640, h: 360 }); + expect(p.pixelScale).toEqual({ x: 1, y: 1 }); + expect(p.blocky).toBe(true); + expect(p.frame).toEqual({ w: 320, h: 180 }); + expect(worldAt(p, 640, 360)).toEqual({ x: 260, y: 40 }); + } + }); + + it('caps the picture and still counts camera pixels', () => { + const p = cameraPicture(camera(), 10); + expect(p.pixels).toEqual({ w: CAMERA_MAX_PIXELS, h: 3600 }); + expect(p.pixelScale.x).toBeCloseTo(640 / CAMERA_MAX_PIXELS); + }); +}); diff --git a/tools/e2e/63-render-layers.mjs b/tools/e2e/63-render-layers.mjs index 6fb3fbd1..15856096 100644 --- a/tools/e2e/63-render-layers.mjs +++ b/tools/e2e/63-render-layers.mjs @@ -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)}); `); } diff --git a/tools/perf-camera-shader.mjs b/tools/perf-camera-shader.mjs new file mode 100644 index 00000000..9faf5c49 --- /dev/null +++ b/tools/perf-camera-shader.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +/** + * What a camera shader costs (#299): the same scene drawn in the running game without one, and with + * one in either resolution. The camera's picture is drawn into a picture of its frame first and the + * shader laid over that – one extra pass for the whole view, whose price is its pixels: the screen's + * at *Screen pixels*, View width × View height at *Camera pixels*. + * + * The shader is a graph with nothing but its Output, which hands the picture through, so every run + * puts the same scene on screen. `GM_PERF_BLUR` puts a Blur of that radius in between instead. + * + * Usage: node tools/seed-e2e.mjs && node tools/perf-camera-shader.mjs + * GM_PERF_COUNTS=... sprite counts (default 100,400) GM_PERF_BLUR=... Blur radius (default none) + * + * In a 1920x1080 window, the Game tab at Fit – the large tab the issue asks for. A measuring tool, + * not a test: frame times depend on the machine, so CI does not run it. Read the rate, not the + * median – see `perf-shaders.mjs` for why, and for the 60 Hz cap. + */ +import { readFileSync } from 'node:fs'; +import { runChecks } from './lib/harness.mjs'; +import { PROJECTS, cliArgs } from './lib/projects.mjs'; + +const { projectId, baseUrl } = cliArgs('perf-camera-shader.mjs', PROJECTS.fixture); + +const COUNTS = (process.env.GM_PERF_COUNTS ?? '100,400').split(',').map(Number); +const BLUR = Number(process.env.GM_PERF_BLUR ?? 0); +const GRAPH = + BLUR > 0 + ? { + nodes: [ + { id: 'pc-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: BLUR } }, + { id: 'pc-out', type: 'ShaderOutput', x: 300, y: 0, params: {} }, + ], + edges: [ + { id: 'pc-c', from: { node: 'pc-blur', port: 'color' }, to: { node: 'pc-out', port: 'color' } }, + { id: 'pc-a', from: { node: 'pc-blur', port: 'alpha' }, to: { node: 'pc-out', port: 'alpha' } }, + ], + } + : { nodes: [{ id: 'pc-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] }; +const MODES = ['none', 'screen', 'camera']; +const WARMUP_MS = 1500; +const SAMPLE_MS = 4000; + +const round = (x, digits = 2) => Number(x.toFixed(digits)); +const load = () => readFileSync('/proc/loadavg', 'utf8').split(' ').slice(0, 2).join(' '); + +await runChecks( + { projectId, baseUrl, port: 9349, shotPrefix: 'gm-perf-camera', width: 1920, height: 1080 }, + async (t) => { + const { check, sleep, json, evaluate, mutate, play, stop, shot, showTab } = t; + + await t.navigate(); + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'perf-camera-fx'); + p.assets.push({ id: 'perf-camera-fx', type: 'shader', name: 'Perf camera', props: {}, + graph: ${JSON.stringify(GRAPH)} }); + __gm.state.setGameScale('fit'); + `); + await showTab('game'); + + /** `n` plain sprites of 48x48 spread over the camera's view, the way `perf-shaders.mjs` lays them. */ + const buildScene = async (n) => { + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const cam = scene.objects.find((o) => o.assetId === 'cam'); + const sprite = p.assets.find((a) => a.type === 'sprite'); + const objects = [cam]; + const cols = Math.ceil(Math.sqrt(${n})); + for (let i = 0; i < ${n}; i++) { + const col = i % cols, row = Math.floor(i / cols); + objects.push({ id: 'perf-' + i, assetId: sprite.id, name: '', + position: { x: cam.position.x - 300 + (col + 0.5) * (600 / cols), + y: cam.position.y - 160 + (row + 0.5) * (320 / cols), z: i }, + editorOffset: { x: 0, y: 0 }, parentId: '', instanceEdits: {}, + overrides: { width: 48, height: 48, texture: '', shader: '', color: 0x4080ff } }); + } + scene.objects = objects; + `); + }; + + /** The camera wears the shader in `mode`, or none. */ + const wear = (mode) => + mutate(` + const cam = __gm.state.activeScene.objects.find((o) => o.assetId === 'cam'); + cam.overrides = { ...cam.overrides, shader: ${mode === 'none' ? "''" : "'perf-camera-fx'"}, + shaderResolution: '${mode === 'camera' ? 'camera' : 'screen'}' }; + `); + + /** Frame times as the page itself sees them, over `SAMPLE_MS` after a warm-up. */ + const sample = async () => { + await sleep(WARMUP_MS); + await evaluate(`(() => { + window.__perfSamples = []; + window.__perfOn = true; + let last = performance.now(); + const tick = () => { + const now = performance.now(); + window.__perfSamples.push(now - last); + last = now; + if (window.__perfOn) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + })()`); + await sleep(SAMPLE_MS); + const frames = await json('(window.__perfOn = false, window.__perfSamples.slice(1))'); + const elapsed = frames.reduce((a, b) => a + b, 0); + return { + frames: frames.length, + mean: round(elapsed / frames.length), + fps: round((frames.length * 1000) / elapsed, 1), + picture: await json('__gm.cameraShaderPixels()'), + }; + }; + + const scale = await json('__gm.gameScale()'); + const rows = []; + for (const n of COUNTS) { + await buildScene(n); + const run = {}; + for (const mode of MODES) { + await wear(mode); + const before = load(); + await play(); + run[mode] = { ...(await sample()), load: before }; + await stop(); + } + check( + `${n} sprites: a camera picture with the shader on, in the size of its resolution, none without`, + run.none.picture === null && + run.camera.picture?.w === 640 && + run.screen.picture !== null && + run.screen.picture.w > 640, + run, + ); + rows.push({ n, ...run }); + console.log( + ` n=${String(n).padStart(4)}` + + MODES.map( + (m) => ` ${m} ${run[m].fps} fps ${JSON.stringify(run[m].picture)} (load ${run[m].load})`, + ).join(''), + ); + } + + await wear('screen'); + await play(); + await sleep(500); + await shot('heaviest'); + await stop(); + + console.log( + `\nGame tab ${Math.round(scale.view.w)}x${Math.round(scale.view.h)} at Fit (factor ${round(scale.factor, 3)}),` + + ` ${BLUR > 0 ? `Blur radius ${BLUR}` : 'pass-through'} camera shader, ${SAMPLE_MS} ms per point`, + ); + console.log('| sprites | no camera shader | Screen pixels | Camera pixels |'); + console.log('| --- | --- | --- | --- |'); + for (const r of rows) + console.log( + `| ${r.n} | ${r.none.fps} | ${r.screen.fps} (${r.screen.picture.w}x${r.screen.picture.h}) |` + + ` ${r.camera.fps} (${r.camera.picture.w}x${r.camera.picture.h}) |`, + ); + console.log(JSON.stringify(rows)); + }, +);