Lay a shader over a camera's whole picture (#299)
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:
1 parent
b2326085ed
commit
2e55071fa5
19 files changed
+773
-31
No files matched your search
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<VisibilityPart, boolean>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<AssetType, PropertySchema> = {
|
||||
{ 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];
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -116,11 +116,12 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
const shaderGraphs = new Map<string, Graph>();
|
||||
const shaderRows = new Map<string, PropertyDef[]>();
|
||||
const particleGraphs = new Map<string, Graph>();
|
||||
const wearers = new Map<string, 'sprite' | 'particles' | 'text'>();
|
||||
const wearers = new Map<string, 'sprite' | 'particles' | 'text' | 'camera'>();
|
||||
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<string, unknown>): PropertySchema | undefined => {
|
||||
const schema = schemas.get(assetId);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<VisibilityPart, string> = { symbol: 'Symbol', label: 'Name', shape: 'Shape' };
|
||||
const PARTS: readonly VisibilityPart[] = ['symbol', 'label', 'shape', 'shader'];
|
||||
const PART_CAPTIONS: Record<VisibilityPart, string> = {
|
||||
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<VisibilityPart> = 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<string, Button>();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<VisibilityPart, string> = {
|
||||
symbol: showSymbol,
|
||||
label: showLabel,
|
||||
shape: showShape,
|
||||
shader,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> extends W
|
||||
override destroy(options?: Parameters<Widget['destroy']>[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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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.
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof cameraPicture>, 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<string, unknown> = {}) => ({
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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)});
|
||||
`);
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user