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

A shader so far worked only on its own wearer. Effects over the whole
game picture, such as a vignette, grey, scanlines or a red flash on a
hit, had no place to live. A camera now wears one, drawn the way a
field shader of a particle source is (#170).

- The camera gets a Shader row and, while one is picked, a Shader
  resolution row. The shader's properties follow as camera rows. They
  load, animate and appear in a Properties node under Type Camera.
- Every view keeps masks and objects in one container. CameraShade
  renders it into the camera's picture, cleared to the background,
  and draws the shader over the frame. Game tab and play.html take
  the container off the stage while the picture stands in for it.
- Screen pixels gives the picture as many pixels as it covers on
  screen. Camera pixels uses View width x View height and scales up
  without smoothing.
- A pixel counts as a camera pixel in both (the user's choice). The
  uniform uPixelScale makes Texture size read View width x View
  height, so a Blur keeps its reach at any game scale.
- Each camera wears its own shader, overlay cameras included. A red
  flash on the game camera leaves the HUD of an overlay camera alone.
- The scene view's eye has a Shader switch on the Camera row, off by
  default. With it on, the main camera's shader lies over its frame,
  below symbols and gizmos, and the world around it stays drawn.
- New tool tools/perf-camera-shader.mjs measures the cost.

Measured on stein2 (HD 630, Game tab at Fit 1310x679): a camera
shader that hands the picture through stays inside the scene's own
spread of 3 to 5 fps between runs. A Blur of 4 cost 4 to 8 fps.

Tests:
- Gate on melektaus-atx: 227 files, 3497 tests (3487 + 10).
- e2e 63 alone: 11 checks (6 + 5), e2e sum 1725 counted.
- 63e-63i: pass-through is byte-equal and upright at 100%, flat red
  reaches the corners while the overlay keeps its green, Texture
  size reads 640 x 360 in both resolutions at Fit, no shader draws
  as before, and the scene view shows it only with the switch on.
- Counter-checks: a camera left out of the shader wearers fails 1
  unit test. Counting screen pixels fails 63g with #ab6000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 16:03:09 +02:00
1 parent b2326085ed
commit 2e55071fa5
19 files changed
+773 -31

No files matched your search

+1 -1
View File
@@ -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));
+9 -4
View File
@@ -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 {
+14 -3
View File
@@ -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];
}
+18
View File
@@ -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';
}
+6 -4
View File
@@ -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);
+3
View File
@@ -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. */
+5 -4
View File
@@ -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',
+13 -6
View File
@@ -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;
}
+3 -1
View File
@@ -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,
};
+12 -3
View File
@@ -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;
}
+64 -1
View File
@@ -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.
+54
View File
@@ -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)));
}
+79
View File
@@ -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;
}
}