Add textures for sprites

Images are uploaded to a content-addressed blob endpoint and stored as
data/textures/<sha256>.<ext>; the project only keeps the file name. The
sprite points at a texture asset through an ordinary property of the new
kind `asset`, so instance overrides, "Apply to container", "Reset to
asset", undo and `Set property` work without any extra code. The new kind
also replaces the ad-hoc asset picker the graph nodes faked as an enum.

Import goes through + -> Texture, drag & drop onto the canvas or Ctrl+V.
Images wider than 2048px are downscaled client-side and re-encoded to
WebP if needed, because nginx caps uploads at 1MB; an image that already
fits is uploaded byte for byte so pixel art is never resampled.

The sprite colour default changes from red to white, since colour tints
the texture multiplicatively and a tint of white shows an image as it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 13:43:08 +02:00
1 parent 6168df41f8
commit 5a6af74e44
47 files changed
+1995 -110

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
import { Assets, type Texture } from 'pixi.js';
import type { TextureRef } from '../core/model/texture';
const DEFAULT_BASE = '/game-maker/data/textures';
/**
* Keeps uploaded images on the GPU, keyed by their file name.
*
* Nothing is ever evicted, and that is safe rather than sloppy: file names are content
* addressed, so a name cannot come to mean different bytes and a cached texture cannot go
* stale. It also means the same picture used by a hundred sprites is uploaded once.
*
* A miss is not an error – it starts the load and reports "not yet", so the caller draws its
* plain colour in the meantime and gets a redraw when the picture arrives. That is why a
* sprite never flickers and why nothing here needs to be awaited.
*/
export class TextureLibrary {
private base = DEFAULT_BASE;
private readonly loaded = new Map<string, Texture>();
private readonly loading = new Set<string>();
/** Files whose load failed; retried never, so one broken reference cannot spam the network. */
private readonly failed = new Set<string>();
private readonly listeners = new Set<() => void>();
/** Where the files are served from. Set once at start-up, before anything draws. */
configure(base: string): void {
this.base = base.replace(/\/+$/, '');
}
/**
* The picture if it is already in memory, otherwise null while it loads.
*
* Caveat of content addressing: two texture *assets* can name the same file (the same image
* uploaded twice) while disagreeing about `smooth`. The scale mode belongs to the shared
* source, so the last one drawn wins – deterministically, since draw order is stable. The
* upload avoids creating that situation by reusing an existing asset for a known file.
*/
get(ref: TextureRef): Texture | null {
const texture = this.loaded.get(ref.file);
if (!texture) {
this.startLoading(ref.file);
return null;
}
const mode = ref.smooth ? 'linear' : 'nearest';
if (texture.source.scaleMode !== mode) texture.source.scaleMode = mode;
return texture;
}
/** Runs `fn` whenever a picture has arrived. Returns the unsubscribe. */
onChange(fn: () => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private startLoading(file: string): void {
if (this.loading.has(file) || this.failed.has(file)) return;
this.loading.add(file);
void Assets.load<Texture>(`${this.base}/${file}`).then(
(texture) => {
this.loading.delete(file);
this.loaded.set(file, texture);
for (const listener of this.listeners) listener();
},
(error: unknown) => {
this.loading.delete(file);
this.failed.add(file);
console.error(`[textures] cannot load ${file}`, error);
},
);
}
}
/**
* The one library. A second instance would only duplicate GPU uploads – the cache is keyed by
* content, so there is nothing an instance could hold that another should not share.
*/
export const textureLibrary = new TextureLibrary();
+25 -1
View File
@@ -15,6 +15,7 @@ import type { EditorState } from '../editor/state';
import { type Size, Theme, Widget } from '../ui';
import type { DrawItem } from './items';
import { drawItem } from './symbols';
import { textureLibrary } from './TextureLibrary';
import { type View, worldBounds, worldToScreen } from './view';
/** What a redraw draws: the items and the colour behind them. */
@@ -58,6 +59,9 @@ export abstract class WorldViewport extends Widget {
state.on('runtime', () => {
if (this.visible) this.redraw();
});
// A picture that finished loading changes what is on screen without anything in the model
// having moved. Unconditional: this fires once per image, not per frame.
textureLibrary.onChange(() => this.redraw());
}
override measure(): Size {
@@ -128,6 +132,26 @@ export abstract class WorldViewport extends Widget {
return { x: origin.x + local.x, y: origin.y + local.y };
}
/**
* The screen rectangle an object occupies: its footprint through the current view. Checks
* sample colours inside it to assert what was actually drawn, which `objectScreen` alone
* cannot support because it reports a point.
*/
objectRect(objectId: string): { x: number; y: number; w: number; h: number } | null {
const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId);
if (!item || !view) return null;
const local = worldToScreen(view, item.x, item.y);
const origin = this.getGlobalPosition();
const size = footprintOf(item.type, item.props);
return {
x: origin.x + local.x,
y: origin.y + local.y,
w: size.w * view.scale,
h: size.h * view.scale,
};
}
// --- drawing ---------------------------------------------------------------------------------
override redraw(): void {
@@ -176,7 +200,7 @@ export abstract class WorldViewport extends Widget {
if (!onScreen) continue;
this.visibleCount++;
node.position.set(item.x, item.y);
drawItem(node, item, size, view.scale);
drawItem(node, item, size, view.scale, textureLibrary);
}
this.afterItems(items, view);
for (const [id, node] of this.nodes) {
+13 -1
View File
@@ -3,6 +3,7 @@ import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { editorPosition, treeOrder } from '../core/model/hierarchy';
import type { PropertyBag } from '../core/model/Properties';
import { findAsset } from '../core/model/Project';
import { resolveTexture, type TextureRef } from '../core/model/texture';
import { enabledAlongChain } from '../core/properties/enabled';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { EditorState } from '../editor/state';
@@ -26,6 +27,13 @@ export interface DrawItem {
parentId: string;
/** Effective switch: this object and all its parents are on (see `properties/enabled.ts`). */
enabled: boolean;
/**
* Picture to draw a sprite with, already resolved from the `texture` property – null for
* every other type and for a sprite without a usable one. It is resolved here because
* drawing has no access to the project: a `DrawItem` is meant to carry everything the
* renderer needs.
*/
texture: TextureRef | null;
}
interface DrawList {
@@ -41,14 +49,16 @@ export function collectSceneItems(state: EditorState, fallbackBackground: number
for (const { object } of treeOrder(scene)) {
const asset = findAsset(state.project, object.assetId);
if (!asset) continue;
const props = resolveSceneObjectProps(asset, object);
items.push({
id: object.id,
type: asset.type,
name: object.name || asset.name,
...editorPosition(scene, object),
props: resolveSceneObjectProps(asset, object),
props,
parentId: object.parentId,
enabled: true,
texture: asset.type === 'sprite' ? resolveTexture(state.project, props) : null,
});
}
const byId = new Map(items.map((i) => [i.id, i]));
@@ -73,6 +83,8 @@ export function collectWorldItems(world: World): DrawList {
props: e.props,
parentId: e.parent?.objectId ?? '',
enabled: true,
// Resolved per frame on purpose: a behavior may swap `texture` with `Set property`.
texture: e.type === 'sprite' ? resolveTexture(world.project, e.props) : null,
}));
return { items, background: world.backgroundColor };
}
+12 -2
View File
@@ -8,6 +8,7 @@ import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/propertie
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
import { Theme } from '../ui';
import type { DrawItem } from './items';
import type { TextureLibrary } from './TextureLibrary';
/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */
const COLLIDER_GRAB_BAND = 6;
@@ -21,13 +22,22 @@ export function drawItem(
item: DrawItem,
size: { w: number; h: number },
scale: number,
textures: TextureLibrary,
): void {
node.clear();
node.hitArea = null;
switch (item.type) {
case 'sprite':
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(Number(item.props.color ?? 0xffffff));
case 'sprite': {
const color = Number(item.props.color ?? 0xffffff);
// Null while the picture is still loading, so the box shows its plain colour until then –
// which is exactly what a sprite looked like before textures existed. No flicker, no
// placeholder. With a picture the colour tints it (verified: white x 0x66aaff reads back
// as rgb(98,163,245)), and Pixi fits an unmatrixed texture fill to the shape's bounds,
// so nothing here has to compute a texture matrix.
const texture = item.texture ? textures.get(item.texture) : null;
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(texture ? { texture, color } : color);
return;
}
case 'camera':
node
.rect(-size.w / 2, -size.h / 2, size.w, size.h)