more clean up

This commit is contained in:
Andras Schmelczer 2026-05-19 21:03:59 +01:00
parent 7c70f15e49
commit c94ffcc506
6 changed files with 514 additions and 0 deletions

View file

@ -0,0 +1,150 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../config';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings';
import { type StrokeSegment } from './game-loop-types';
interface BrushStrokeSmootherOptions {
getCanvasPixelRatio: () => number;
getMirrorSegmentCount: () => number;
}
export class BrushStrokeSmoother {
private readonly strokePoints: Array<vec2> = [];
private lastBrushPosition: vec2 | null = null;
public constructor(private readonly options: BrushStrokeSmootherOptions) {}
public addSample(position: vec2): Array<StrokeSegment> {
const previousSample = this.strokePoints[this.strokePoints.length - 1];
if (
previousSample !== undefined &&
vec2.squaredDistance(previousSample, position) <=
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
) {
return [];
}
this.strokePoints.push(vec2.clone(position));
if (this.strokePoints.length > 3) {
this.strokePoints.shift();
}
if (this.strokePoints.length === 1) {
this.lastBrushPosition = vec2.clone(position);
return [{ from: position, to: position }];
}
if (this.strokePoints.length === 2) {
const [start, end] = this.strokePoints;
const midpoint = getMidpoint(start, end);
this.lastBrushPosition = midpoint;
return [{ from: start, to: midpoint }];
}
const [start, control, end] = this.strokePoints;
const curveStart = getMidpoint(start, control);
const curveEnd = getMidpoint(control, end);
this.lastBrushPosition = curveEnd;
return this.getQuadraticSegments(curveStart, control, curveEnd);
}
public finish(): Array<StrokeSegment> {
if (this.strokePoints.length === 0) {
return [];
}
const finalSample = this.strokePoints[this.strokePoints.length - 1];
if (
this.lastBrushPosition !== null &&
vec2.squaredDistance(this.lastBrushPosition, finalSample) >
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
) {
return [{ from: this.lastBrushPosition, to: finalSample }];
}
return [];
}
public clear(): void {
this.strokePoints.length = 0;
this.lastBrushPosition = null;
}
public scale(scale: vec2): void {
this.strokePoints.forEach((point) => {
vec2.mul(point, point, scale);
});
if (this.lastBrushPosition !== null) {
vec2.mul(this.lastBrushPosition, this.lastBrushPosition, scale);
}
}
private getQuadraticSegments(
start: vec2,
control: vec2,
end: vec2
): Array<StrokeSegment> {
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
const canvasPixelRatio = getSafePixelRatio(this.options.getCanvasPixelRatio());
const brushRadius = Math.max(
settings.brushCurveMinBrushRadius * canvasPixelRatio,
(settings.brushSize * canvasPixelRatio) / 2
);
const segmentSpacing = Math.max(
settings.brushCurveMinSegmentSpacing * canvasPixelRatio,
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
);
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
const curveResolution = getBrushCurveResolution();
const maxCurveSegments = Math.max(
1,
Math.floor(
curveResolution /
Math.max(1, mirrorSegmentCount ** settings.brushCurveMirrorResolutionExponent)
)
);
const segmentCount = Math.min(
maxCurveSegments,
Math.max(1, Math.ceil(curveLength / segmentSpacing))
);
let previousPoint = start;
const segments: Array<StrokeSegment> = [];
for (let i = 1; i <= segmentCount; i++) {
const point = getQuadraticPoint(start, control, end, i / segmentCount);
segments.push({ from: previousPoint, to: point });
previousPoint = point;
}
return segments;
}
}
const getMidpoint = (from: vec2, to: vec2): vec2 =>
vec2.fromValues((from[0] + to[0]) / 2, (from[1] + to[1]) / 2);
const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): vec2 => {
const inverseT = 1 - t;
return vec2.fromValues(
inverseT * inverseT * start[0] + 2 * inverseT * t * control[0] + t * t * end[0],
inverseT * inverseT * start[1] + 2 * inverseT * t * control[1] + t * t * end[1]
);
};
const getBrushCurveResolution = (): number => {
const resolution = Number.isFinite(settings.brushCurveResolution)
? settings.brushCurveResolution
: appConfig.defaultSettings.brushCurveResolution;
return Math.max(1, Math.floor(resolution));
};
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
? settings.brushSmoothingMinSampleDistance
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
};

View file

@ -0,0 +1,71 @@
import { EraserPreview } from './eraser-preview';
interface EraserPointerPreviewControllerOptions {
canvas: HTMLCanvasElement;
eraserPreview: EraserPreview;
getIsSwipeActive: () => boolean;
}
export class EraserPointerPreviewController {
public constructor(private readonly options: EraserPointerPreviewControllerOptions) {}
public attach(): void {
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
this.canvas.addEventListener('pointerdown', this.onPointerDown);
this.canvas.addEventListener('pointermove', this.onPointerMove);
this.canvas.addEventListener('pointerup', this.onPointerUp);
this.canvas.addEventListener('pointercancel', this.onPointerUp);
}
public detach(): void {
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
this.canvas.removeEventListener('pointermove', this.onPointerMove);
this.canvas.removeEventListener('pointerup', this.onPointerUp);
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
}
public setEraseMode(isErasing: boolean): void {
this.options.eraserPreview.setEraseMode(isErasing, this.isSwipeActive);
}
public update(event?: PointerEvent): void {
this.options.eraserPreview.update(event, this.isSwipeActive);
}
private get canvas(): HTMLCanvasElement {
return this.options.canvas;
}
private get isSwipeActive(): boolean {
return this.options.getIsSwipeActive();
}
private readonly onPointerDown = (event: PointerEvent) => {
this.options.eraserPreview.setPointerHoveringCanvas(true);
this.update(event);
};
private readonly onPointerMove = (event: PointerEvent) => {
this.update(event);
};
private readonly onPointerUp = (event: PointerEvent) => {
this.options.eraserPreview.setPointerHoveringCanvas(
this.options.eraserPreview.isPointerInsideCanvas(event)
);
this.update(event);
};
private readonly onPointerEnter = (event: PointerEvent) => {
this.options.eraserPreview.setPointerHoveringCanvas(true);
this.update(event);
};
private readonly onPointerLeave = () => {
this.options.eraserPreview.setPointerHoveringCanvas(false);
this.update();
};
}

View file

@ -0,0 +1,202 @@
import { appConfig } from '../config';
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import type { VibeId } from '../vibes';
interface ExportSnapshotRendererOptions {
device: GPUDevice;
renderPipeline: RenderPipeline;
statusElement: HTMLElement;
seed: string;
getSourceSize: () => { width: number; height: number };
getColorTextureView: () => GPUTextureView;
getSourceTextureView: () => GPUTextureView;
getVibeId: () => VibeId;
}
interface SnapshotLayout {
width: number;
height: number;
unpaddedBytesPerRow: number;
bytesPerRow: number;
readbackBufferBytes: number;
}
export class ExportSnapshotRenderer {
private isExporting = false;
public constructor(private readonly options: ExportSnapshotRendererOptions) {}
public async export(): Promise<void> {
if (this.isExporting) {
this.statusElement.textContent = 'Snapshot already saving...';
return;
}
this.isExporting = true;
this.statusElement.textContent = 'Saving snapshot...';
try {
const sourceSize = this.options.getSourceSize();
await this.renderSnapshot(getSnapshotLayout(sourceSize.width, sourceSize.height));
this.statusElement.textContent = '';
} catch (error) {
this.statusElement.textContent = 'Snapshot failed';
throw error;
} finally {
this.isExporting = false;
}
}
private async renderSnapshot(layout: SnapshotLayout): Promise<void> {
const { width, height, unpaddedBytesPerRow, bytesPerRow } = layout;
const format = navigator.gpu.getPreferredCanvasFormat();
let texture: GPUTexture | null = null;
let output: GPUBuffer | null = null;
let isOutputMapped = false;
try {
texture = this.device.createTexture({
size: { width, height },
format,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
});
output = this.device.createBuffer({
size: layout.readbackBufferBytes,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
const commandEncoder = this.device.createCommandEncoder();
this.options.renderPipeline.executeToView(
commandEncoder,
this.options.getColorTextureView(),
this.options.getSourceTextureView(),
texture.createView()
);
commandEncoder.copyTextureToBuffer(
{ texture },
{ buffer: output, bytesPerRow, rowsPerImage: height },
{ width, height }
);
this.device.queue.submit([commandEncoder.finish()]);
await output.mapAsync(GPUMapMode.READ);
isOutputMapped = true;
const pixels = readSnapshotPixels({
mapped: new Uint8Array(output.getMappedRange()),
width,
height,
unpaddedBytesPerRow,
bytesPerRow,
isBgra: format === 'bgra8unorm',
});
output.unmap();
isOutputMapped = false;
output.destroy();
output = null;
texture.destroy();
texture = null;
await this.downloadPixels(pixels, width, height);
} finally {
if (output && isOutputMapped) {
output.unmap();
}
output?.destroy();
texture?.destroy();
}
}
private async downloadPixels(
pixels: Uint8ClampedArray<ArrayBuffer>,
width: number,
height: number
): Promise<void> {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not create export canvas');
}
context.putImageData(new ImageData(pixels, width, height), 0, 0);
const blob = await canvas.convertToBlob({
type: appConfig.exportSnapshot.mimeType,
});
const link = document.createElement('a');
const objectUrl = URL.createObjectURL(blob);
try {
link.href = objectUrl;
link.download = `${appConfig.exportSnapshot.filenamePrefix}_${this.options.getVibeId()}_${
this.options.seed
}_${width}x${height}${appConfig.exportSnapshot.filenameSuffix}.${appConfig.exportSnapshot.filenameExtension}`;
link.click();
} finally {
URL.revokeObjectURL(objectUrl);
}
}
private get device(): GPUDevice {
return this.options.device;
}
private get statusElement(): HTMLElement {
return this.options.statusElement;
}
}
const alignTo = (value: number, alignment: number): number =>
Math.ceil(value / alignment) * alignment;
const getSnapshotDimension = (value: number): number =>
Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : 1;
const getSnapshotLayout = (sourceWidth: number, sourceHeight: number): SnapshotLayout => {
const width = getSnapshotDimension(sourceWidth);
const height = getSnapshotDimension(sourceHeight);
const unpaddedBytesPerRow = width * appConfig.exportSnapshot.bytesPerPixel;
const bytesPerRow = alignTo(
unpaddedBytesPerRow,
appConfig.exportSnapshot.rowAlignmentBytes
);
return {
width,
height,
unpaddedBytesPerRow,
bytesPerRow,
readbackBufferBytes: bytesPerRow * height,
};
};
const readSnapshotPixels = ({
mapped,
width,
height,
unpaddedBytesPerRow,
bytesPerRow,
isBgra,
}: {
mapped: Uint8Array;
width: number;
height: number;
unpaddedBytesPerRow: number;
bytesPerRow: number;
isBgra: boolean;
}): Uint8ClampedArray<ArrayBuffer> => {
const pixels: Uint8ClampedArray<ArrayBuffer> = new Uint8ClampedArray(
unpaddedBytesPerRow * height
);
for (let y = 0; y < height; y++) {
const sourceOffset = y * bytesPerRow;
const targetOffset = y * unpaddedBytesPerRow;
for (let x = 0; x < width; x++) {
const source = sourceOffset + x * appConfig.exportSnapshot.bytesPerPixel;
const target = targetOffset + x * appConfig.exportSnapshot.bytesPerPixel;
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
pixels[target + 1] = mapped[source + 1];
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
pixels[target + 3] = mapped[source + 3];
}
}
return pixels;
};

View file

@ -0,0 +1,42 @@
import { vec2 } from 'gl-matrix';
import { type StrokeSegment } from './game-loop-types';
export const getMirroredStrokeSegments = (
from: vec2,
to: vec2,
canvasSize: vec2,
segmentCount: number
): Array<StrokeSegment> => {
if (segmentCount <= 1) {
return [{ from, to }];
}
const center = vec2.fromValues(canvasSize[0] / 2, canvasSize[1] / 2);
const angleStep = (Math.PI * 2) / segmentCount;
const segments: Array<StrokeSegment> = [];
for (let i = 0; i < segmentCount; i++) {
const angle = angleStep * i;
segments.push({
from: rotatePointAround(from, center, angle),
to: rotatePointAround(to, center, angle),
});
}
return segments;
};
const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
if (angle === 0) {
return point;
}
const offsetX = point[0] - center[0];
const offsetY = point[1] - center[1];
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return vec2.fromValues(
center[0] + offsetX * cos - offsetY * sin,
center[1] + offsetX * sin + offsetY * cos
);
};

View file

@ -0,0 +1,34 @@
import { vec2 } from 'gl-matrix';
import { type BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { type EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
import { type EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
export interface StrokeOutput {
addBrushSegment(from: vec2, to: vec2): void;
addEraseSegment(from: vec2, to: vec2): void;
clearSwipes(): void;
}
export class PipelineStrokeOutput implements StrokeOutput {
public constructor(
private readonly brushPipeline: BrushPipeline,
private readonly eraserAgentPipeline: EraserAgentPipeline,
private readonly eraserTexturePipeline: EraserTexturePipeline
) {}
public addBrushSegment(from: vec2, to: vec2): void {
this.brushPipeline.addSwipeSegment(from, to);
}
public addEraseSegment(from: vec2, to: vec2): void {
this.eraserAgentPipeline.addSwipeSegment();
this.eraserTexturePipeline.addSwipeSegment(from, to);
}
public clearSwipes(): void {
this.brushPipeline.clearSwipes();
this.eraserAgentPipeline.clearSwipes();
this.eraserTexturePipeline.clearSwipes();
}
}

15
src/utils/rgb-color.ts Normal file
View file

@ -0,0 +1,15 @@
export type RgbColor = [red: number, green: number, blue: number];
const RGB_CHANNEL_MAX = 255;
const toFiniteRgbChannel = (value: number): number =>
Number.isFinite(value) ? value : 0;
const clampRgbChannel = (value: number): number =>
Math.min(RGB_CHANNEL_MAX, Math.max(0, Math.round(toFiniteRgbChannel(value))));
export const rgbColorToCss = ([red, green, blue]: RgbColor): string =>
`rgb(${clampRgbChannel(red)}, ${clampRgbChannel(green)}, ${clampRgbChannel(blue)})`;
export const rgbChannelToUnit = (value: number): number =>
Math.min(1, Math.max(0, toFiniteRgbChannel(value) / RGB_CHANNEL_MAX));