more clean up
This commit is contained in:
parent
7c70f15e49
commit
c94ffcc506
6 changed files with 514 additions and 0 deletions
150
src/game-loop/brush-stroke-smoother.ts
Normal file
150
src/game-loop/brush-stroke-smoother.ts
Normal 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;
|
||||
};
|
||||
71
src/game-loop/eraser-pointer-preview-controller.ts
Normal file
71
src/game-loop/eraser-pointer-preview-controller.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
202
src/game-loop/export-snapshot-renderer.ts
Normal file
202
src/game-loop/export-snapshot-renderer.ts
Normal 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;
|
||||
};
|
||||
42
src/game-loop/stroke-mirroring.ts
Normal file
42
src/game-loop/stroke-mirroring.ts
Normal 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
|
||||
);
|
||||
};
|
||||
34
src/game-loop/stroke-output.ts
Normal file
34
src/game-loop/stroke-output.ts
Normal 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
15
src/utils/rgb-color.ts
Normal 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));
|
||||
Loading…
Add table
Add a link
Reference in a new issue