A frame's collision outline can be generated from its pixels (#161)
Drawing an outline by hand for every frame of a sheet is slow, and the outline mostly follows the opaque part of the image anyway. The texture tab now traces it: one click, a corner budget, and every Frame collision collider and tile using that frame blocks with the result. - "Generate collision..." sits in the texture tab header and is off while the texture has no frames. - The dialog has Max points (default 8, 3 to 64), Solid from ... % alpha (default 50) and All frames of the texture. A live line says what Generate will write, and Generate stays off when nothing is solid enough. - traceCollision walks each island of solid pixels along the pixel edges, then drops the least important corners until at most N are left and no stair step remains. A slope becomes one straight edge. - An outline never crosses itself. Where the image would force that, the island's convex hull is taken instead. - Crumbs under 1 % of the frame area are dropped unless they are the only island. Holes are not reproduced. - Existing outlines are replaced. One frame or all frames is a single undo step. - TextureLibrary hands out the sheet's RGBA data, and a pure function cuts a frame's region from it. Findings: - The wedge in the e2e fixture gets 4 corners, not 3. Its tip is one pixel wide in the image, and that real edge stays while corners are still free. Tests: - ./check.sh: 131 files, 1941 tests, CHECK EXIT 0 (was 130 / 1931). - 10 new unit tests in tests/core/trace-collision.test.ts, including 150 random shapes that must all be valid and within the budget. - GM_STEPS=42, freshly seeded: ALL PASSED, 20 checks (was 16). - Three counter-probes bite: no hull fallback, no stair threshold, dialog ignoring the alpha threshold. - Check sum 1202 in README is computed (1198 + 4), CI measures it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f4ec23d3d3
commit
2d79230b55
10 files changed
+705
-5
No files matched your search
@@ -150,6 +150,33 @@ export class SetFrameCollisionCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the outlines of several frames at once – what "Generate collision…" writes (#161), one
|
||||
* undo step whether it covered one frame or the whole sheet.
|
||||
*/
|
||||
export class SetFramesCollisionCommand implements Command {
|
||||
readonly label = 'Generate collision';
|
||||
private previous = new Map<string, Vec2[][]>();
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly outlines: ReadonlyMap<string, readonly (readonly Vec2[])[]>,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireTexture(project, this.assetId);
|
||||
if (this.outlines.size === 0) throw new CommandError('No frame to generate a collision outline for');
|
||||
const frames = [...this.outlines.keys()].map((id) => requireFrame(asset, id));
|
||||
this.previous = new Map(frames.map((frame) => [frame.id, frameCollision(frame)]));
|
||||
for (const frame of frames) setFrameCollision(frame, this.outlines.get(frame.id)!);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const asset = requireTexture(project, this.assetId);
|
||||
for (const [id, outlines] of this.previous) setFrameCollision(requireFrame(asset, id), outlines);
|
||||
}
|
||||
}
|
||||
|
||||
export class RenameFrameCommand implements Command {
|
||||
readonly label = 'Rename frame';
|
||||
private previous = '';
|
||||
|
||||
@@ -67,7 +67,7 @@ export function cleanOutline(points: readonly Vec2[]): Vec2[] {
|
||||
}
|
||||
|
||||
/** Whether the open segments ab and cd cross or touch. */
|
||||
function segmentsMeet(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean {
|
||||
export function segmentsMeet(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean {
|
||||
const d1 = cross(c, d, a);
|
||||
const d2 = cross(c, d, b);
|
||||
const d3 = cross(a, b, c);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* A frame's collision outlines read off its picture (#161): the pixels at least as opaque as the
|
||||
* threshold are solid, every island of them is walked round along the pixel edges, and the walk is
|
||||
* cut down to a few corners. Holes are not traced – an outline blocks with all it encloses.
|
||||
*/
|
||||
import { polygonProblem, segmentsMeet } from './polygon';
|
||||
import type { Vec2 } from './Properties';
|
||||
|
||||
export interface TraceOptions {
|
||||
/** The most corners one outline keeps. */
|
||||
maxPoints: number;
|
||||
/** Opacity from which a pixel is solid, 0 … 1. */
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_TRACE: TraceOptions = { maxPoints: 8, threshold: 0.5 };
|
||||
export const MIN_TRACE_POINTS = 3;
|
||||
export const MAX_TRACE_POINTS = 64;
|
||||
|
||||
/** An island below this share of the frame is a crumb and gets no outline – unless it is the largest. */
|
||||
const CRUMB_SHARE = 0.01;
|
||||
/** A corner making a triangle this small (px²) with its neighbours is a step of the pixel grid. */
|
||||
const STEP_AREA = 1;
|
||||
|
||||
/**
|
||||
* The outlines of the solid islands, in the frame's own pixels. `alpha` holds one opacity 0 … 255
|
||||
* per pixel, row by row. Islands are 4-connected: pixels meeting at a corner only are two islands.
|
||||
*/
|
||||
export function traceOutlines(
|
||||
alpha: ArrayLike<number>,
|
||||
width: number,
|
||||
height: number,
|
||||
options: TraceOptions,
|
||||
): Vec2[][] {
|
||||
const w = Math.floor(width);
|
||||
const h = Math.floor(height);
|
||||
if (w <= 0 || h <= 0 || alpha.length < w * h) return [];
|
||||
const threshold = Math.min(1, Math.max(0, Number.isFinite(options.threshold) ? options.threshold : 0.5));
|
||||
const cut = Math.max(1, Math.ceil(threshold * 255 - 1e-9));
|
||||
const solid = new Uint8Array(w * h);
|
||||
for (let i = 0; i < w * h; i++) solid[i] = alpha[i]! >= cut ? 1 : 0;
|
||||
|
||||
const islands = islandsOf(solid, w, h);
|
||||
if (islands.length === 0) return [];
|
||||
const largest = Math.max(...islands.map((island) => island.area));
|
||||
const maxPoints = clampPoints(options.maxPoints);
|
||||
return islands
|
||||
.filter((island) => island.area === largest || island.area >= CRUMB_SHARE * w * h)
|
||||
.map((island) => {
|
||||
const walk = walkRound(solid, w, h, island.start % w, Math.floor(island.start / w));
|
||||
const outline = simplify(walk, maxPoints);
|
||||
const kept = outline.length <= maxPoints && !polygonProblem(outline);
|
||||
return kept ? outline : simplify(convexHull(walk), maxPoints);
|
||||
});
|
||||
}
|
||||
|
||||
/** The opacities of `rect` out of RGBA bytes of a picture `sheetWidth` wide; outside it is clear. */
|
||||
export function alphaOfRect(
|
||||
rgba: ArrayLike<number>,
|
||||
sheetWidth: number,
|
||||
sheetHeight: number,
|
||||
rect: { x: number; y: number; w: number; h: number },
|
||||
): Uint8Array {
|
||||
const alpha = new Uint8Array(rect.w * rect.h);
|
||||
for (let y = 0; y < rect.h; y++)
|
||||
for (let x = 0; x < rect.w; x++) {
|
||||
const sx = rect.x + x;
|
||||
const sy = rect.y + y;
|
||||
if (sx >= 0 && sy >= 0 && sx < sheetWidth && sy < sheetHeight)
|
||||
alpha[y * rect.w + x] = rgba[(sy * sheetWidth + sx) * 4 + 3]!;
|
||||
}
|
||||
return alpha;
|
||||
}
|
||||
|
||||
export function clampPoints(value: number): number {
|
||||
const n = Math.round(Number.isFinite(value) ? value : DEFAULT_TRACE.maxPoints);
|
||||
return Math.min(MAX_TRACE_POINTS, Math.max(MIN_TRACE_POINTS, n));
|
||||
}
|
||||
|
||||
interface Island {
|
||||
/** Index of its first pixel in reading order – the top-left corner of that pixel is on its rim. */
|
||||
start: number;
|
||||
area: number;
|
||||
}
|
||||
|
||||
function islandsOf(solid: Uint8Array, w: number, h: number): Island[] {
|
||||
const seen = new Uint8Array(w * h);
|
||||
const islands: Island[] = [];
|
||||
const stack: number[] = [];
|
||||
for (let start = 0; start < w * h; start++) {
|
||||
if (!solid[start] || seen[start]) continue;
|
||||
seen[start] = 1;
|
||||
stack.push(start);
|
||||
let area = 0;
|
||||
while (stack.length > 0) {
|
||||
const i = stack.pop()!;
|
||||
area++;
|
||||
const x = i % w;
|
||||
for (const j of [x > 0 ? i - 1 : -1, x < w - 1 ? i + 1 : -1, i - w, i + w])
|
||||
if (j >= 0 && j < w * h && solid[j] && !seen[j]) {
|
||||
seen[j] = 1;
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
islands.push({ start, area });
|
||||
}
|
||||
return islands;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rim of the island whose first pixel is (sx, sy), as the corners where it turns, walked with
|
||||
* the island on the right – clockwise on screen. Where two pixels meet at a corner only, the walk
|
||||
* turns away from the diagonal one: it belongs to another island.
|
||||
*/
|
||||
function walkRound(solid: Uint8Array, w: number, h: number, sx: number, sy: number): Vec2[] {
|
||||
const inside = (x: number, y: number) => x >= 0 && y >= 0 && x < w && y < h && solid[y * w + x] === 1;
|
||||
const corners: Vec2[] = [];
|
||||
let [x, y, dx, dy] = [sx, sy, 1, 0];
|
||||
for (let guard = 4 * (w + 1) * (h + 1); guard > 0; guard--) {
|
||||
x += dx;
|
||||
y += dy;
|
||||
const [rx, ry] = [-dy, dx];
|
||||
const right = inside(Math.floor(x + (dx + rx) / 2), Math.floor(y + (dy + ry) / 2));
|
||||
const left = inside(Math.floor(x + (dx - rx) / 2), Math.floor(y + (dy - ry) / 2));
|
||||
if (right && left) [dx, dy] = [dy, -dx];
|
||||
else if (!right) [dx, dy] = [-dy, dx];
|
||||
if (!right || left) corners.push({ x, y });
|
||||
if (x === sx && y === sy && dx === 1 && dy === 0) break;
|
||||
}
|
||||
return corners;
|
||||
}
|
||||
|
||||
function triangleArea(a: Vec2, b: Vec2, c: Vec2): number {
|
||||
return Math.abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes away the corner that changes the outline least (Visvalingam–Whyatt) until at most
|
||||
* `maxPoints` are left and none is a mere step of the pixel grid. A corner whose removal would
|
||||
* make the outline cross itself stays.
|
||||
*/
|
||||
function simplify(ring: readonly Vec2[], maxPoints: number): Vec2[] {
|
||||
const points = [...ring];
|
||||
const areaAt = (i: number) =>
|
||||
triangleArea(
|
||||
points[(i + points.length - 1) % points.length]!,
|
||||
points[i]!,
|
||||
points[(i + 1) % points.length]!,
|
||||
);
|
||||
const areas = points.map((_, i) => areaAt(i));
|
||||
while (points.length > MIN_TRACE_POINTS) {
|
||||
const refused = new Set<number>();
|
||||
let removed = false;
|
||||
for (;;) {
|
||||
let best = -1;
|
||||
for (let i = 0; i < points.length; i++)
|
||||
if (!refused.has(i) && (best < 0 || areas[i]! < areas[best]!)) best = i;
|
||||
if (best < 0 || (points.length <= maxPoints && areas[best]! >= STEP_AREA)) break;
|
||||
if (!removable(points, best)) {
|
||||
refused.add(best);
|
||||
continue;
|
||||
}
|
||||
points.splice(best, 1);
|
||||
areas.splice(best, 1);
|
||||
const before = (best + points.length - 1) % points.length;
|
||||
areas[before] = areaAt(before);
|
||||
areas[best % points.length] = areaAt(best % points.length);
|
||||
removed = true;
|
||||
break;
|
||||
}
|
||||
if (!removed) break;
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** Whether the edge that replaces corner `i` stays clear of every edge it does not share a corner with. */
|
||||
function removable(points: readonly Vec2[], i: number): boolean {
|
||||
const n = points.length;
|
||||
const a = (i + n - 1) % n;
|
||||
const c = (i + 1) % n;
|
||||
const from = points[a]!;
|
||||
const to = points[c]!;
|
||||
for (let j = 0; j < n; j++) {
|
||||
if (j === (a + n - 1) % n || j === a || j === i || j === c) continue;
|
||||
if (segmentsMeet(from, to, points[j]!, points[(j + 1) % n]!)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The hull of the points – the outline of last resort for a pinched island. */
|
||||
function convexHull(points: readonly Vec2[]): Vec2[] {
|
||||
const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
|
||||
const turn = (o: Vec2, a: Vec2, b: Vec2) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
||||
const half = (list: Vec2[]) => {
|
||||
const out: Vec2[] = [];
|
||||
for (const p of list) {
|
||||
while (out.length >= 2 && turn(out[out.length - 2]!, out[out.length - 1]!, p) <= 0) out.pop();
|
||||
out.push(p);
|
||||
}
|
||||
out.pop();
|
||||
return out;
|
||||
};
|
||||
return [...half(sorted), ...half([...sorted].reverse())];
|
||||
}
|
||||
@@ -459,6 +459,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
rect: () => textureEditor.canvasRect(),
|
||||
/** Whether a press edits the selected frame's collision outline (#141). */
|
||||
collisionMode: () => textureEditor.canvas.collisionMode,
|
||||
/** The open "Generate collision…" dialog (#161), or null. */
|
||||
generateDialog: () => textureEditor.generateDialog.current()?.info ?? null,
|
||||
},
|
||||
/** The Vector art tab (#129, stage 3): where a canvas pixel is, and which tool a press uses. */
|
||||
vectorArt: {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import {
|
||||
DEFAULT_TRACE,
|
||||
MAX_TRACE_POINTS,
|
||||
MIN_TRACE_POINTS,
|
||||
type TraceOptions,
|
||||
} from '../../core/model/traceCollision';
|
||||
import { type Button, Checkbox, Label, NumberField, type Widget } from '../../ui';
|
||||
import { FormDialog } from '../FormDialog';
|
||||
|
||||
const FIELD_WIDTH = 76;
|
||||
|
||||
/** A frame's outlines as the picture gives them, or null while the picture is not in memory. */
|
||||
export type TraceFrame = (frame: TextureFrame, options: TraceOptions) => Vec2[][] | null;
|
||||
|
||||
/**
|
||||
* Reads the collision outlines of a frame – or of every frame – off the picture (#161). The
|
||||
* summary traces as the numbers change, so what OK writes is known before it is pressed.
|
||||
*/
|
||||
export class GenerateCollisionDialog extends FormDialog {
|
||||
private readonly options: TraceOptions = { ...DEFAULT_TRACE };
|
||||
private all: boolean;
|
||||
private readonly summary: Label;
|
||||
private readonly confirm: Button;
|
||||
private readonly controls: Record<'points' | 'threshold' | 'all', Widget>;
|
||||
private result = new Map<string, Vec2[][]>();
|
||||
|
||||
constructor(
|
||||
private readonly asset: TextureAsset,
|
||||
private readonly selected: TextureFrame | null,
|
||||
private readonly trace: TraceFrame,
|
||||
onGenerate: (outlines: Map<string, Vec2[][]>) => void,
|
||||
) {
|
||||
super(340, `Generate collision for "${asset.name}"`);
|
||||
this.all = !selected;
|
||||
|
||||
const points = new NumberField(this.options.maxPoints, {
|
||||
min: MIN_TRACE_POINTS,
|
||||
max: MAX_TRACE_POINTS,
|
||||
step: 1,
|
||||
decimals: 0,
|
||||
onInput: (value) => this.setOption('maxPoints', value),
|
||||
onChange: (value) => this.setOption('maxPoints', value),
|
||||
});
|
||||
points.minWidth = FIELD_WIDTH;
|
||||
this.content.add(this.formRow(this.fieldLabel('Max points'), points));
|
||||
|
||||
const threshold = new NumberField(this.options.threshold * 100, {
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
decimals: 0,
|
||||
onInput: (value) => this.setOption('threshold', value / 100),
|
||||
onChange: (value) => this.setOption('threshold', value / 100),
|
||||
});
|
||||
threshold.minWidth = FIELD_WIDTH;
|
||||
this.content.add(
|
||||
this.formRow(this.fieldLabel('Solid from'), threshold, new Label('% alpha', { style: 'muted' })),
|
||||
);
|
||||
|
||||
const box = new Checkbox(this.all);
|
||||
box.setEnabled(selected !== null);
|
||||
box.onChange = (value) => {
|
||||
this.all = Boolean(value);
|
||||
this.refreshSummary();
|
||||
};
|
||||
this.content.add(this.formRow(box, new Label('All frames of the texture', { style: 'muted' })));
|
||||
this.content.add(new Label('Replaces the outlines these frames have.', { style: 'muted' }));
|
||||
|
||||
this.summary = new Label('', { style: 'muted' });
|
||||
this.content.add(this.summary);
|
||||
|
||||
this.confirm = this.addButtons('Generate', 'primary', () => onGenerate(this.result));
|
||||
this.controls = { points, threshold, all: box };
|
||||
this.refreshSummary();
|
||||
}
|
||||
|
||||
/** What the summary says, whether OK would write anything, and where the controls sit – for the check scripts. */
|
||||
get info(): {
|
||||
summary: string;
|
||||
enabled: boolean;
|
||||
all: boolean;
|
||||
at: Record<'points' | 'threshold' | 'all', { x: number; y: number }>;
|
||||
} {
|
||||
const centre = (widget: Widget) => {
|
||||
const { x, y } = widget.toGlobal({ x: widget.w / 2, y: widget.h / 2 });
|
||||
return { x, y };
|
||||
};
|
||||
const { points, threshold, all } = this.controls;
|
||||
const at = { points: centre(points), threshold: centre(threshold), all: centre(all) };
|
||||
return { summary: this.summary.text, enabled: this.confirm.enabled, all: this.all, at };
|
||||
}
|
||||
|
||||
private setOption<K extends keyof TraceOptions>(key: K, value: TraceOptions[K]): void {
|
||||
this.options[key] = value;
|
||||
this.refreshSummary();
|
||||
}
|
||||
|
||||
private refreshSummary(): void {
|
||||
const frames = this.all || !this.selected ? this.asset.frames : [this.selected];
|
||||
this.result = new Map();
|
||||
let waiting = false;
|
||||
for (const frame of frames) {
|
||||
const outlines = this.trace(frame, this.options);
|
||||
if (outlines) this.result.set(frame.id, outlines);
|
||||
else waiting = true;
|
||||
}
|
||||
const outlines = [...this.result.values()].flat();
|
||||
const corners = outlines.reduce((sum, outline) => sum + outline.length, 0);
|
||||
const scope = frames.length === 1 ? `Frame "${frames[0]!.name}"` : `${frames.length} frames`;
|
||||
const text = waiting
|
||||
? 'The picture is still loading.'
|
||||
: outlines.length === 0
|
||||
? 'No pixel is that opaque – no outline.'
|
||||
: `${scope}: ${plural(outlines.length, 'outline')}, ${plural(corners, 'corner')}`;
|
||||
this.summary.setText(text);
|
||||
this.confirm.setEnabled(!waiting && outlines.length > 0);
|
||||
this.refreshLayout();
|
||||
}
|
||||
}
|
||||
|
||||
function plural(count: number, word: string): string {
|
||||
return `${count} ${word}${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { SliceFramesCommand } from '../../core/commands/frameCommands';
|
||||
import { SetFramesCollisionCommand, SliceFramesCommand } from '../../core/commands/frameCommands';
|
||||
import type { Asset, TextureAsset } from '../../core/model/Asset';
|
||||
import { alphaOfRect, traceOutlines } from '../../core/model/traceCollision';
|
||||
import { Button } from '../../ui';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { dialogSlot } from '../FormDialog';
|
||||
import type { EditorState } from '../state';
|
||||
import { GenerateCollisionDialog, type TraceFrame } from './GenerateCollisionDialog';
|
||||
import { SliceDialog } from './SliceDialog';
|
||||
import { TextureCanvas } from './TextureCanvas';
|
||||
|
||||
@@ -13,6 +17,8 @@ export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
|
||||
private readonly sliceButton: Button;
|
||||
/** Switches the canvas between editing frames and editing their collision outlines (#141). */
|
||||
private readonly collisionButton: Button;
|
||||
private readonly generateButton: Button;
|
||||
readonly generateDialog = dialogSlot<GenerateCollisionDialog>();
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
@@ -21,7 +27,8 @@ export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
|
||||
toggle: true,
|
||||
onClick: () => this.setCollisionMode(!this.canvas.collisionMode),
|
||||
});
|
||||
this.mount(new TextureCanvas(state), [this.sliceButton, this.collisionButton]);
|
||||
this.generateButton = new Button('Generate collision…', { onClick: () => this.openGenerate() });
|
||||
this.mount(new TextureCanvas(state), [this.sliceButton, this.collisionButton, this.generateButton]);
|
||||
}
|
||||
|
||||
setCollisionMode(on: boolean): void {
|
||||
@@ -56,6 +63,24 @@ export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
|
||||
protected override syncExtras(asset: TextureAsset | null): void {
|
||||
// Slicing needs a known picture size; without one it could only fail with a notice.
|
||||
this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0));
|
||||
// Outlines belong to frames; a texture without one has nowhere to put them.
|
||||
this.generateButton.setEnabled(Boolean(asset && asset.frames.length > 0));
|
||||
}
|
||||
|
||||
private openGenerate(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset || asset.frames.length === 0) return;
|
||||
const s = this.state.selection;
|
||||
const selected = (s?.kind === 'frame' && asset.frames.find((f) => f.id === s.frameId)) || null;
|
||||
const pixels = textureLibrary.pixels(asset.file);
|
||||
const trace: TraceFrame = (frame, options) =>
|
||||
pixels &&
|
||||
traceOutlines(alphaOfRect(pixels.data, pixels.width, pixels.height, frame), frame.w, frame.h, options);
|
||||
this.generateDialog.open(
|
||||
new GenerateCollisionDialog(asset, selected, trace, (outlines) =>
|
||||
this.state.execute(new SetFramesCollisionCommand(asset.id, outlines)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private openSlice(): void {
|
||||
|
||||
@@ -121,6 +121,21 @@ export class TextureLibrary extends FileLibrary<Texture> {
|
||||
return texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* The colours of an uploaded picture, read back from the image it was loaded from; null while it
|
||||
* loads, and for a generated picture, which lives on the GPU only.
|
||||
*/
|
||||
pixels(file: string): ImageData | null {
|
||||
const sheet = isGeneratedFile(file) ? null : this.load(file);
|
||||
const picture = sheet?.source.resource as CanvasImageSource | null | undefined;
|
||||
if (!sheet || !picture) return null;
|
||||
const { pixelWidth: w, pixelHeight: h } = sheet.source;
|
||||
const context = new OffscreenCanvas(w, h).getContext('2d', { willReadFrequently: true });
|
||||
if (!context) return null;
|
||||
context.drawImage(picture, 0, 0);
|
||||
return context.getImageData(0, 0, w, h);
|
||||
}
|
||||
|
||||
protected fetchFile(url: string): Promise<Texture> {
|
||||
return Assets.load<Texture>(url);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user