Vector shapes can fill with gradients and textures (#186)
game-maker / gate (push) Successful in 2m14s
game-maker / suite (push) Failing after 7m14s

A vector shape could only be filled and outlined with one flat colour.
Shading, glows and patterned surfaces had to be faked with stacks of
shapes. Every shape now has a paint type for its fill and its outline.

- Fill type: solid colour, linear gradient, radial gradient or
  texture. Line type offers the same without texture.
- Colour stops are edited on a bar in the inspector, as agreed in the
  issue. A click on the bar adds a stop in the colour the gradient
  already has there, so adding never changes the picture. At least two
  stops remain.
- The new Paint tool in the tab shows two handles for the selected
  shape: axis start and end, centre and radius, or the corners of one
  texture tile. "Handles: Fill / Outline" picks which paint is edited.
- Moving a whole shape moves its gradients and tile with it. Every
  gesture is one undo step.
- A texture fill always tiles, because Pixi turns a non-repeating
  pattern into a repeating one anyway. When switched on, one tile
  covers the whole shape.
- Only uploaded textures and frames can be a fill, never a drawing.
  A drawing filled with itself would rasterise itself mid-raster.
- The fingerprint of a drawing includes the texture and frame a fill
  points to, so the picture redraws when the image is swapped.

Findings:

- graphics.destroy(true) also destroyed the textures of the fills.
  With a texture fill it deleted the tile slice and the next redraw
  crashed on reading 'scaleMode' of null. It is now destroy().
- The gradient cache destroyed old gradients itself, which gave 20
  Pixi warnings "destroyed while still bound". They are now handed to
  Pixi's texture GC with autoGarbageCollect switched on. The console
  of module 47 is empty again, the last CI log had 0 of them.

Tests:

- ./check.sh: 135 files, 2011 tests, exit 0 (before 134 / 1986)
- npm run build: exit 0
- GM_STEPS=47, freshly seeded: ALL PASSED, 54 checks (before 44),
  0 console warnings
- 25 new unit tests, 10 new e2e checks in block 47i
- Four counter-checks bite, originals restored and verified with cmp
- Check sum 1232 in README and wiki, calculated (1222 + 10)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 13:05:02 +02:00
1 parent 1116818011
commit 592ea942d9
22 files changed
+2090 -98

No files matched your search

+2 -2
View File
@@ -76,7 +76,7 @@ export class GeneratedPictures<T> {
* Who draws an asset type, keyed by type as a plain string for the same reason the readers in
* `core/model/generated.ts` are: a plugin may bring a type of its own.
*/
export type PictureGenerator = (asset: Asset) => Texture | null;
export type PictureGenerator = (asset: Asset, project: Project) => Texture | null;
const generators = new Map<string, PictureGenerator>();
@@ -99,6 +99,6 @@ export function projectPictureFactory(projectOf: () => Project | null): PictureF
return (assetId) => {
const project = projectOf();
const asset = project ? findAsset(project, assetId) : null;
return asset ? (pictureGeneratorFor(asset.type)?.(asset) ?? null) : null;
return project && asset ? (pictureGeneratorFor(asset.type)?.(asset, project) ?? null) : null;
};
}
+139 -17
View File
@@ -13,12 +13,23 @@
* The render runs inside the ask for the picture, which is safe because every caller asks while it
* redraws its own widgets and the frame itself is rendered afterwards, on the ticker (`ui/render`).
*/
import { Graphics, GraphicsPath, RenderTexture, type Renderer, type Texture } from 'pixi.js';
import {
FillGradient,
type FillStyle,
Graphics,
GraphicsPath,
Matrix,
RenderTexture,
type Renderer,
type Texture,
} from 'pixi.js';
import type { Asset } from '../core/model/Asset';
import { registerPictureSource } from '../core/model/generated';
import type { Project } from '../core/model/Project';
import { vectorArtPicture } from '../core/model/vectorart';
import { type PathOp, type ShapeDrawing, vectorArtCanvas } from '../core/model/vectorPath';
import { type DrawPaint, type PathOp, type ShapeDrawing, vectorArtCanvas } from '../core/model/vectorPath';
import { registerPictureGenerator } from './generatedPictures';
import { textureLibrary } from './TextureLibrary';
/**
* How much finer than the canvas the picture is rasterised (the user's answer: 2x). A drawing on a
@@ -30,8 +41,8 @@ export const SUPERSAMPLE = 2;
/** Registers vector art in both halves of the picture path; the returned function takes it out. */
export function installVectorArtPictures(renderer: Renderer): () => void {
const removeSource = registerPictureSource('vectorart', vectorArtPicture);
const removeGenerator = registerPictureGenerator('vectorart', (asset) =>
rasteriseVectorArt(asset, renderer),
const removeGenerator = registerPictureGenerator('vectorart', (asset, project) =>
rasteriseVectorArt(asset, renderer, project),
);
return () => {
removeSource();
@@ -43,40 +54,151 @@ export function installVectorArtPictures(renderer: Renderer): () => void {
* The asset as a texture, or null when it is not vector art at all. An asset without a single
* drawable shape still gets its canvas: an empty picture is what an empty drawing looks like, and a
* sprite wearing it keeps its size while the first shape is being drawn.
*
* Null too while a picture it is filled with is still loading (#186). Nothing is remembered for a
* null, so the next ask – the redraw that follows the picture landing – rasterises it complete,
* instead of a drawing with a hole in it staying on screen under a key that never changes.
*/
export function rasteriseVectorArt(asset: Asset, renderer: Renderer): Texture | null {
const canvas = vectorArtCanvas(asset);
export function rasteriseVectorArt(asset: Asset, renderer: Renderer, project?: Project): Texture | null {
const canvas = vectorArtCanvas(asset, asset.props, project);
if (!canvas) return null;
const graphics = new Graphics();
const paints = new PaintCache();
let complete = true;
for (const drawing of canvas.drawings) complete = paintDrawing(graphics, drawing, paints) && complete;
if (!complete) {
release(graphics, paints);
return null;
}
const texture = RenderTexture.create({
width: canvas.width,
height: canvas.height,
resolution: SUPERSAMPLE,
antialias: true,
});
const graphics = new Graphics();
for (const drawing of canvas.drawings) paintDrawing(graphics, drawing);
renderer.render({ container: graphics, target: texture, clear: true });
// The picture is on the GPU from here on; the scene graph that drew it is of no more use.
graphics.destroy(true);
release(graphics, paints);
return texture;
}
/**
* Lets the drawing go, but not the pictures it filled with. `destroy(true)` would destroy every
* texture the context's fills name – a picture fill samples the library's own cut of a texture
* (#186), and a destroyed cut left the next redraw reading a texture without a source.
*/
function release(graphics: Graphics, paints: PaintCache): void {
graphics.destroy();
paints.destroy();
}
/**
* The gradients one painter keeps (#186). Each `FillGradient` builds a texture of its own, so a
* canvas that repaints on every pointer move would pile them up; one asked for again with the same
* stops is reused, and one not asked for between two sweeps is let go.
*
* Let go, not destroyed: the frame drawn last still has the gradient's texture bound, and destroying
* it there makes Pixi warn "destroyed while still bound to a shader" once per gradient. Handed to the
* texture garbage collector instead, it is unloaded once it has not been drawn for a while.
*/
export class PaintCache {
private readonly held = new Map<string, FillGradient>();
private readonly used = new Set<string>();
gradient(paint: Extract<DrawPaint, { stops: unknown }>): FillGradient {
const key = JSON.stringify(paint);
this.used.add(key);
let gradient = this.held.get(key);
if (!gradient) {
const colorStops = paint.stops.map((stop) => ({
offset: stop.offset,
color: [
((stop.color >> 16) & 0xff) / 255,
((stop.color >> 8) & 0xff) / 255,
(stop.color & 0xff) / 255,
stop.alpha,
],
}));
gradient =
paint.type === 'linear'
? new FillGradient({
type: 'linear',
start: paint.from,
end: paint.to,
colorStops,
textureSpace: 'global',
})
: new FillGradient({
type: 'radial',
center: paint.from,
innerRadius: 0,
outerCenter: paint.from,
outerRadius: Math.hypot(paint.to.x - paint.from.x, paint.to.y - paint.from.y),
colorStops,
textureSpace: 'global',
});
this.held.set(key, gradient);
}
return gradient;
}
/** Lets go of what was not asked for since the last sweep; call it after a full repaint. */
sweep(): void {
for (const [key, gradient] of this.held)
if (!this.used.has(key)) {
// Built lazily on first use: a gradient never drawn has no texture to hand over.
const source = (gradient.texture as Texture | undefined)?.source;
if (source) source.autoGarbageCollect = true;
this.held.delete(key);
}
this.used.clear();
}
destroy(): void {
this.used.clear();
this.sweep();
}
}
/**
* One shape: its fill first, its outline over it, so a stroke is never half swallowed by the fill.
* Exported because the Vector art tab paints the very same drawing onto its own `Graphics` (#129,
* stage 3) – what one edits there has to be what a sprite ends up wearing.
*/
export function paintDrawing(graphics: Graphics, drawing: ShapeDrawing): void {
export function paintDrawing(graphics: Graphics, drawing: ShapeDrawing, paints: PaintCache): boolean {
let complete = true;
if (drawing.fill.length > 0) {
const fill = fillOf(drawing.paint, drawing.color, paints);
complete = fill !== null;
// `checkForHoles` is what makes a ring inside another cut it out instead of painting over it.
graphics.path(pathOf(drawing.fill, true)).fill({ color: drawing.color, alpha: drawing.alpha });
graphics
.path(pathOf(drawing.fill, true))
.fill({ ...(fill ?? { color: drawing.color }), alpha: drawing.alpha });
}
if (drawing.stroke.length > 0)
graphics.path(pathOf(drawing.stroke, false)).stroke({
color: drawing.strokeColor,
width: drawing.strokeWidth,
alpha: drawing.alpha,
});
if (drawing.stroke.length > 0) {
const line = fillOf(drawing.strokePaint, drawing.strokeColor, paints) ?? { color: drawing.strokeColor };
graphics
.path(pathOf(drawing.stroke, false))
.stroke({ ...line, width: drawing.strokeWidth, alpha: drawing.alpha });
}
return complete;
}
/**
* How a paint fills, or null while its picture is still loading. A picture fill samples its own cut
* of the texture with `repeat`: the library's sheets are `mirror-repeat` (see `TextureLibrary`), and
* a `FillPattern` would switch the address mode on the shared source under every sprite wearing it.
* The matrix takes texture pixels to canvas pixels, so one tile spans `from` to `to`.
*/
function fillOf(paint: DrawPaint | null, color: number, paints: PaintCache): FillStyle | null {
if (!paint) return { color };
if (paint.type !== 'texture') return { fill: paints.gradient(paint) };
const texture = textureLibrary.get(paint.ref, 'repeat');
if (!texture) return null;
const matrix = new Matrix()
.scale((paint.to.x - paint.from.x) / texture.width, (paint.to.y - paint.from.y) / texture.height)
.translate(paint.from.x, paint.from.y);
return { texture, matrix, textureSpace: 'global' };
}
function pathOf(ops: readonly PathOp[], checkForHoles: boolean): GraphicsPath {