diff --git a/src/game-loop/brush-stroke-smoother.ts b/src/game-loop/brush-stroke-smoother.ts new file mode 100644 index 0000000..80dd4b9 --- /dev/null +++ b/src/game-loop/brush-stroke-smoother.ts @@ -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 = []; + private lastBrushPosition: vec2 | null = null; + + public constructor(private readonly options: BrushStrokeSmootherOptions) {} + + public addSample(position: vec2): Array { + 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 { + 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 { + 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 = []; + 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; +}; diff --git a/src/game-loop/eraser-pointer-preview-controller.ts b/src/game-loop/eraser-pointer-preview-controller.ts new file mode 100644 index 0000000..38ffdee --- /dev/null +++ b/src/game-loop/eraser-pointer-preview-controller.ts @@ -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(); + }; +} diff --git a/src/game-loop/export-snapshot-renderer.ts b/src/game-loop/export-snapshot-renderer.ts new file mode 100644 index 0000000..bf6b309 --- /dev/null +++ b/src/game-loop/export-snapshot-renderer.ts @@ -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 { + 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 { + 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, + width: number, + height: number + ): Promise { + 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 => { + const pixels: Uint8ClampedArray = 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; +}; diff --git a/src/game-loop/stroke-mirroring.ts b/src/game-loop/stroke-mirroring.ts new file mode 100644 index 0000000..9bfb8d4 --- /dev/null +++ b/src/game-loop/stroke-mirroring.ts @@ -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 => { + 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 = []; + 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 + ); +}; diff --git a/src/game-loop/stroke-output.ts b/src/game-loop/stroke-output.ts new file mode 100644 index 0000000..6680525 --- /dev/null +++ b/src/game-loop/stroke-output.ts @@ -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(); + } +} diff --git a/src/utils/rgb-color.ts b/src/utils/rgb-color.ts new file mode 100644 index 0000000..5cb0465 --- /dev/null +++ b/src/utils/rgb-color.ts @@ -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));