Rework garden simulation pipelines

This commit is contained in:
Andras Schmelczer 2026-05-24 10:58:11 +01:00
parent c2efb33683
commit 018f8c9d4d
67 changed files with 6544 additions and 1727 deletions

View file

@ -1,37 +1,26 @@
import { clamp } from './clamp';
import { exponentialDecay } from './exponential-decay';
import { appConfig } from '../config';
import { clamp } from './math';
export class DeltaTimeCalculator {
private static FPS_EXPONENTIAL_DECAY_STRENGTH = 0.01;
private previousTime: DOMHighResTimeStamp | null = null;
private deltaTimeAccumulator: number | null = null;
private readonly visibilityChangeListener = () => this.handleVisibilityChange();
constructor(
private readonly maxDeltaTimeInSeconds: number = 1 / 30,
private readonly minDeltaTimeInSeconds: number = 1 / 240
) {
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
constructor() {
document.addEventListener('visibilitychange', this.visibilityChangeListener);
}
public calculateDeltaTimeInSeconds(
currentTime: DOMHighResTimeStamp
): DOMHighResTimeStamp {
public calculateDeltaTimeInSeconds(currentTime: DOMHighResTimeStamp): number {
if (this.previousTime === null) {
this.previousTime = currentTime;
}
const delta = currentTime - this.previousTime;
this.previousTime = currentTime;
const deltaInSeconds = delta / 1000;
this.deltaTimeAccumulator = exponentialDecay({
accumulator: this.deltaTimeAccumulator ?? deltaInSeconds,
nextValue: deltaInSeconds,
biasOfNextValue: DeltaTimeCalculator.FPS_EXPONENTIAL_DECAY_STRENGTH,
});
return clamp(delta / 1000, this.minDeltaTimeInSeconds, this.maxDeltaTimeInSeconds);
return clamp(
delta / 1000,
appConfig.deltaTime.minDeltaTimeSeconds,
appConfig.deltaTime.maxDeltaTimeSeconds
);
}
private handleVisibilityChange() {
@ -40,7 +29,7 @@ export class DeltaTimeCalculator {
}
}
public get fps() {
return this.deltaTimeAccumulator ? 1 / this.deltaTimeAccumulator : 0;
public destroy(): void {
document.removeEventListener('visibilitychange', this.visibilityChangeListener);
}
}

View file

@ -4,42 +4,231 @@ export enum Severity {
ERROR = 'error',
}
export interface ErrorHandlerError {
severity: Severity;
message: string;
export enum ErrorCode {
WEBGPU_INSECURE_CONTEXT = 'webgpu-insecure-context',
WEBGPU_UNSUPPORTED = 'webgpu-unsupported',
WEBGPU_ADAPTER_UNAVAILABLE = 'webgpu-adapter-unavailable',
WEBGPU_DEVICE_UNAVAILABLE = 'webgpu-device-unavailable',
WEBGPU_CONTEXT_UNAVAILABLE = 'webgpu-context-unavailable',
WEBGPU_CONTEXT_CONFIGURATION_FAILED = 'webgpu-context-configuration-failed',
WEBGPU_UNCAPTURED_ERROR = 'webgpu-uncaptured-error',
WEBGPU_DEVICE_LOST = 'webgpu-device-lost',
DOM_ELEMENT_MISSING = 'dom-element-missing',
}
export type ErrorMetadata = { [key: string]: any };
type ErrorMetadataPrimitive = string | number | boolean | null;
type ErrorMetadataValue =
| ErrorMetadataPrimitive
| Array<ErrorMetadataValue>
| { [key: string]: ErrorMetadataValue };
type ErrorMetadata = { [key: string]: ErrorMetadataValue };
interface RuntimeErrorOptions {
cause?: unknown;
details?: Record<string, unknown>;
}
export class RuntimeError extends Error {
public readonly code: ErrorCode | string;
public readonly details: ErrorMetadata;
public constructor(
code: ErrorCode | string,
message: string,
{ cause, details = {} }: RuntimeErrorOptions = {}
) {
super(message);
this.name = 'RuntimeError';
this.code = code;
this.details = serializeMetadataValue(details) as ErrorMetadata;
if (cause !== undefined) {
this.cause = cause;
}
}
}
interface ErrorHandlerError {
severity: Severity;
message: string;
code?: ErrorCode | string;
details?: ErrorMetadata;
}
interface ErrorHandlerErrorOptions {
code?: ErrorCode | string;
details?: Record<string, unknown>;
}
interface ErrorHandlerExceptionOptions extends ErrorHandlerErrorOptions {
fallbackMessage?: string;
severity?: Severity;
}
const MAX_METADATA_DEPTH = 4;
const UNREADABLE_VALUE = '[Unreadable]';
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const safelyRead = (value: Record<string, unknown>, key: string): unknown => {
try {
return value[key];
} catch {
return undefined;
}
};
const isIterable = (value: unknown): value is Iterable<unknown> =>
isRecord(value) && Symbol.iterator in value;
const serializeMetadataValue = (value: unknown, depth = 0): ErrorMetadataValue => {
if (value === null) {
return null;
}
switch (typeof value) {
case 'string':
case 'boolean':
return value;
case 'number':
return Number.isFinite(value) ? value : value.toString();
case 'bigint':
return value.toString();
case 'undefined':
return null;
case 'symbol':
return value.toString();
case 'function':
return `[Function ${value.name || 'anonymous'}]`;
}
if (depth >= MAX_METADATA_DEPTH) {
return '[Object]';
}
if (Array.isArray(value)) {
return value.map((item) => serializeMetadataValue(item, depth + 1));
}
if (isIterable(value)) {
try {
return Array.from(value, (item) => serializeMetadataValue(item, depth + 1));
} catch {
return UNREADABLE_VALUE;
}
}
const serialized: ErrorMetadata = {};
const record = value as Record<string, unknown>;
for (const key of Object.keys(record)) {
try {
serialized[key] = serializeMetadataValue(record[key], depth + 1);
} catch {
serialized[key] = UNREADABLE_VALUE;
}
}
return serialized;
};
export const getErrorMessage = (
exception: unknown,
fallbackMessage = 'Unknown error'
): string => {
if (typeof exception === 'string') {
return exception || fallbackMessage;
}
if (exception instanceof Error) {
const record = exception as unknown as Record<string, unknown>;
const message = safelyRead(record, 'message');
if (typeof message === 'string' && message.length > 0) {
return message;
}
const name = safelyRead(record, 'name');
if (typeof name === 'string' && name.length > 0) {
return name;
}
return fallbackMessage;
}
if (isRecord(exception)) {
const message = safelyRead(exception, 'message');
if (typeof message === 'string' && message.length > 0) {
return message;
}
}
if (
typeof exception === 'number' ||
typeof exception === 'boolean' ||
typeof exception === 'bigint' ||
typeof exception === 'symbol'
) {
return exception.toString();
}
return fallbackMessage;
};
export class ErrorHandler {
private static readonly errors: Array<ErrorHandlerError> = [];
private static metadata: ErrorMetadata = {};
private static onErrorListeners: Array<
(error: ErrorHandlerError, metadata: ErrorMetadata) => void
> = [];
public static addException(exception: Error) {
ErrorHandler.addError(Severity.ERROR, exception.message);
public static addException(
exception: unknown,
{
severity = Severity.ERROR,
fallbackMessage,
code,
details,
}: ErrorHandlerExceptionOptions = {}
) {
const runtimeError = exception instanceof RuntimeError ? exception : undefined;
ErrorHandler.addError(severity, getErrorMessage(exception, fallbackMessage), {
code: code ?? runtimeError?.code,
details: {
...(runtimeError?.details ?? {}),
...(details ?? {}),
},
});
}
public static addError(severity: Severity, message: string) {
ErrorHandler.errors.push({ severity, message });
public static addError(
severity: Severity,
message: string,
{ code, details }: ErrorHandlerErrorOptions = {}
) {
const error: ErrorHandlerError = {
severity,
message,
...(code === undefined ? {} : { code }),
...(details === undefined
? {}
: { details: serializeMetadataValue(details) as ErrorMetadata }),
};
ErrorHandler.onErrorListeners.forEach((listener) =>
listener({ severity, message }, ErrorHandler.metadata)
listener(error, ErrorHandler.metadata)
);
}
public static addMetadata(key: string, value: any) {
const serialized: Record<string, any> = {};
for (const k in value) {
serialized[k] = value[k];
}
ErrorHandler.metadata[key] = serialized;
public static addMetadata(key: string, value: unknown) {
ErrorHandler.metadata[key] = serializeMetadataValue(value);
}
public static addOnErrorListener(
listener: (error: ErrorHandlerError, metadata: ErrorMetadata) => void
) {
): () => void {
ErrorHandler.onErrorListeners.push(listener);
return () => {
ErrorHandler.onErrorListeners = ErrorHandler.onErrorListeners.filter(
(registeredListener) => registeredListener !== listener
);
};
}
}

View file

@ -0,0 +1,38 @@
type BindGroupCacheKeys = readonly [object, ...object[]];
interface BindGroupCacheNode {
bindGroup?: GPUBindGroup;
children: WeakMap<object, BindGroupCacheNode>;
}
const createNode = (): BindGroupCacheNode => ({
children: new WeakMap(),
});
const getOrCreateNode = (
children: WeakMap<object, BindGroupCacheNode>,
key: object
): BindGroupCacheNode => {
let node = children.get(key);
if (!node) {
node = createNode();
children.set(key, node);
}
return node;
};
export const createBindGroupCache = <Keys extends BindGroupCacheKeys>(
factory: (...keys: Keys) => GPUBindGroup
): ((...keys: Keys) => GPUBindGroup) => {
const root = new WeakMap<object, BindGroupCacheNode>();
return (...keys) => {
let node = getOrCreateNode(root, keys[0]);
for (const key of keys.slice(1)) {
node = getOrCreateNode(node.children, key);
}
node.bindGroup ??= factory(...keys);
return node.bindGroup;
};
};

View file

@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest';
import {
createCachedBufferWrite,
updateCachedBufferWrite,
writeBufferIfChanged,
} from './cached-buffer-write';
describe('cached buffer writes', () => {
it('compares raw bytes so aliased uint changes are detected', () => {
const values = new Float32Array(1);
const uintValues = new Uint32Array(values.buffer);
const cache = createCachedBufferWrite(values.byteLength);
uintValues[0] = 0x7fc00001;
expect(updateCachedBufferWrite(values, cache)).toBe(true);
expect(updateCachedBufferWrite(values, cache)).toBe(false);
uintValues[0] = 0x7fc00002;
expect(Number.isNaN(values[0])).toBe(true);
expect(updateCachedBufferWrite(values, cache)).toBe(true);
});
it('writes to the GPU queue only when the raw buffer changed', () => {
const values = new Uint32Array([1, 2, 3, 4]);
const writeBuffer = vi.fn();
const device = { queue: { writeBuffer } } as unknown as GPUDevice;
const buffer = {} as GPUBuffer;
const cache = createCachedBufferWrite(values.byteLength);
expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(true);
expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(false);
values[2] = 5;
expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(true);
expect(writeBuffer).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,46 @@
interface CachedBufferWrite {
hasValue: boolean;
previous: Uint8Array;
}
export const createCachedBufferWrite = (byteLength: number): CachedBufferWrite => ({
hasValue: false,
previous: new Uint8Array(byteLength),
});
export const updateCachedBufferWrite = (
values: ArrayBufferView,
cache: CachedBufferWrite
): boolean => {
const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
if (bytes.length !== cache.previous.length) {
throw new Error('Cached buffer write length mismatch');
}
let hasChanged = !cache.hasValue;
for (let i = 0; i < bytes.length && !hasChanged; i++) {
hasChanged = bytes[i] !== cache.previous[i];
}
if (!hasChanged) {
return false;
}
cache.previous.set(bytes);
cache.hasValue = true;
return true;
};
export const writeBufferIfChanged = (
device: GPUDevice,
buffer: GPUBuffer,
values: ArrayBufferView,
cache: CachedBufferWrite
): boolean => {
if (!updateCachedBufferWrite(values, cache)) {
return false;
}
device.queue.writeBuffer(buffer, 0, values);
return true;
};

View file

@ -1,65 +1,25 @@
import { smartCompile } from './smart-compile';
export const setUpFullScreenQuad = (
device: GPUDevice
): {
buffer: GPUBuffer;
vertex: GPUVertexState;
} => {
const buffer = device.createBuffer({
size: 4 * 4 * Float32Array.BYTES_PER_ELEMENT, // 4 x vec4<f32>
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true,
});
// prettier-ignore
const vertexData = [
// posX posY U V
-1.0, -1.0, 0.0, 1.0,
+1.0, -1.0, 1.0, 1.0,
-1.0, +1.0, 0.0, 0.0,
+1.0, +1.0, 1.0, 0.0,
];
new Float32Array(buffer.getMappedRange()).set(vertexData);
buffer.unmap();
export const setUpFullScreenQuad = (device: GPUDevice): GPUVertexState => ({
module: smartCompile(
device,
/* wgsl */ `
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
return {
buffer,
vertex: {
module: smartCompile(
device,
/* wgsl */ `
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn vertex(
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>
) -> VertexOutput {
return VertexOutput(vec4(position, 0.0, 1.0), uv);
}`
),
entryPoint: 'vertex',
buffers: [
{
arrayStride: 4 * Float32Array.BYTES_PER_ELEMENT,
stepMode: 'vertex',
attributes: [
{
shaderLocation: 0,
offset: 0,
format: 'float32x2',
},
{
shaderLocation: 1,
offset: 8,
format: 'float32x2',
},
],
},
],
},
};
};
@vertex
fn vertex(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
let positions = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(3.0, -1.0),
vec2<f32>(-1.0, 3.0)
);
let position = positions[vertexIndex];
let uv = vec2<f32>(position.x * 0.5 + 0.5, 0.5 - position.y * 0.5);
return VertexOutput(vec4(position, 0.0, 1.0), uv);
}`
),
entryPoint: 'vertex',
});

View file

@ -1,28 +0,0 @@
export const getWorkgroupCounts = (
device: GPUDevice,
invocationCount: number,
workgroupSize: number
): [number, number, number] => {
const workgroupCount = Math.ceil(invocationCount / workgroupSize);
const workgroupCountX = Math.min(
device.limits.maxComputeWorkgroupsPerDimension,
workgroupCount
);
const workgroupCountY = Math.min(
device.limits.maxComputeWorkgroupsPerDimension,
Math.ceil(workgroupCount / workgroupCountX)
);
const workgroupCountZ = Math.min(
device.limits.maxComputeWorkgroupsPerDimension,
Math.ceil(workgroupCount / workgroupCountX / workgroupCountY)
);
if (workgroupCountX * workgroupCountY * workgroupCountZ < workgroupCount) {
throw new Error('Cannot have this many invocations');
}
return [workgroupCountX, workgroupCountY, workgroupCountZ];
};

View file

@ -1,17 +1,50 @@
import { ErrorCode, getErrorMessage, RuntimeError } from '../error-handler';
export const initializeContext = ({
device,
canvas,
format,
}: {
device: GPUDevice;
canvas: HTMLCanvasElement;
format: GPUTextureFormat;
}): GPUCanvasContext => {
const context = canvas.getContext('webgpu') as any as GPUCanvasContext;
const context = canvas.getContext('webgpu');
context.configure({
device: device,
format: navigator.gpu.getPreferredCanvasFormat(),
alphaMode: 'premultiplied',
});
if (!context) {
throw new RuntimeError(
ErrorCode.WEBGPU_CONTEXT_UNAVAILABLE,
'Could not create a WebGPU canvas context.',
{
details: {
canvasHeight: canvas.height,
canvasWidth: canvas.width,
},
}
);
}
try {
context.configure({
device: device,
format,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
alphaMode: 'opaque',
});
} catch (error) {
throw new RuntimeError(
ErrorCode.WEBGPU_CONTEXT_CONFIGURATION_FAILED,
'Could not configure the WebGPU canvas context.',
{
cause: error,
details: {
causeMessage: getErrorMessage(error),
canvasHeight: canvas.height,
canvasWidth: canvas.width,
},
}
);
}
return context;
};

View file

@ -1,33 +1,150 @@
import { ErrorHandler, Severity } from '../error-handler';
import {
ErrorCode,
ErrorHandler,
getErrorMessage,
RuntimeError,
Severity,
} from '../error-handler';
const WEBGPU_BROWSER_SUPPORT_MESSAGE =
'Fleeting Garden needs WebGPU. Try the latest Chrome, Edge, or another browser with WebGPU enabled.';
const REQUESTED_LIMIT_NAMES = [
'maxBufferSize',
'maxStorageBufferBindingSize',
'maxComputeWorkgroupsPerDimension',
] as const satisfies ReadonlyArray<keyof GPUSupportedLimits>;
const getRequiredLimits = (
limits: GPUSupportedLimits
): Record<(typeof REQUESTED_LIMIT_NAMES)[number], number> =>
Object.fromEntries(REQUESTED_LIMIT_NAMES.map((name) => [name, limits[name]])) as Record<
(typeof REQUESTED_LIMIT_NAMES)[number],
number
>;
const getAdapterInfo = (adapter: GPUAdapter): Record<string, unknown> => {
try {
const info = adapter.info;
return {
architecture: info.architecture,
description: info.description,
device: info.device,
isFallbackAdapter: info.isFallbackAdapter,
subgroupMaxSize: info.subgroupMaxSize,
subgroupMinSize: info.subgroupMinSize,
vendor: info.vendor,
};
} catch (error) {
return {
unavailableReason: getErrorMessage(error),
};
}
};
const requestAdapter = async (
gpu: GPU,
options?: GPURequestAdapterOptions
): Promise<GPUAdapter | null> => {
try {
return await gpu.requestAdapter(options);
} catch (error) {
throw new RuntimeError(
ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
'Could not request a WebGPU adapter.',
{
cause: error,
details: {
causeMessage: getErrorMessage(error),
powerPreference: options?.powerPreference ?? 'default',
},
}
);
}
};
export const initializeGpu = async (): Promise<GPUDevice> => {
if (window.isSecureContext === false) {
throw new RuntimeError(
ErrorCode.WEBGPU_INSECURE_CONTEXT,
'WebGPU requires a secure context. Open Fleeting Garden over HTTPS or from localhost.'
);
}
const gpu = navigator.gpu;
if (!gpu) {
throw new Error('WebGPU is not supported in your browser');
throw new RuntimeError(ErrorCode.WEBGPU_UNSUPPORTED, WEBGPU_BROWSER_SUPPORT_MESSAGE, {
details: {
hasNavigatorGpu: false,
isSecureContext: window.isSecureContext,
},
});
}
const adapter = await gpu.requestAdapter({
powerPreference: 'high-performance',
});
const adapter =
(await requestAdapter(gpu, {
powerPreference: 'high-performance',
})) ?? (await requestAdapter(gpu));
if (!adapter) {
throw new Error('Could not request adatper');
throw new RuntimeError(
ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
'WebGPU is available, but this browser could not provide a compatible GPU adapter.'
);
}
ErrorHandler.addMetadata('features', adapter.features);
ErrorHandler.addMetadata('limits', adapter.limits);
const gpuDevice = await adapter.requestDevice({
requiredLimits: {
maxBufferSize: adapter.limits.maxBufferSize,
maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
maxComputeWorkgroupsPerDimension: adapter.limits.maxComputeWorkgroupsPerDimension,
},
const requiredLimits = getRequiredLimits(adapter.limits);
const requiredFeatures: Array<GPUFeatureName> = [];
if (adapter.features.has('timestamp-query')) {
requiredFeatures.push('timestamp-query');
}
ErrorHandler.addMetadata('webgpuAdapter', {
features: Array.from(adapter.features).sort(),
info: getAdapterInfo(adapter),
requiredFeatures,
requiredLimits,
});
let gpuDevice: GPUDevice;
try {
gpuDevice = await adapter.requestDevice({
requiredFeatures,
requiredLimits,
});
} catch (error) {
throw new RuntimeError(
ErrorCode.WEBGPU_DEVICE_UNAVAILABLE,
'Could not create a WebGPU device for this adapter.',
{
cause: error,
details: {
causeMessage: getErrorMessage(error),
requiredFeatures,
requiredLimits,
},
}
);
}
gpuDevice.addEventListener('uncapturederror', (event: GPUUncapturedErrorEvent) =>
ErrorHandler.addError(Severity.ERROR, event.error.message)
ErrorHandler.addException(event.error, {
code: ErrorCode.WEBGPU_UNCAPTURED_ERROR,
severity: Severity.ERROR,
})
);
gpuDevice.lost.then((info) => {
if (info.reason === 'destroyed') {
return;
}
ErrorHandler.addError(Severity.ERROR, info.message || 'The WebGPU device was lost.', {
code: ErrorCode.WEBGPU_DEVICE_LOST,
details: {
reason: info.reason,
},
});
});
return gpuDevice;
};

View file

@ -1,7 +1,11 @@
import { appConfig } from '../../config';
import { setUpFullScreenQuad } from './full-screen-quad';
import { smartCompile } from './smart-compile';
const textureCache = new Map<string, GPUTexture>();
export interface GeneratedNoiseTexture {
texture: GPUTexture;
view: GPUTextureView;
}
export const generateNoise = ({
device,
@ -11,15 +15,8 @@ export const generateNoise = ({
device: GPUDevice;
width: number;
height: number;
}): GPUTextureView => {
const cacheKey = `${width}x${height}`;
const cached = textureCache.get(cacheKey);
if (cached) {
return cached.createView();
}
const { buffer, vertex } = setUpFullScreenQuad(device);
const vertexBuffer = buffer;
}): GeneratedNoiseTexture => {
const vertex = setUpFullScreenQuad(device);
const pipeline = device.createRenderPipeline({
layout: 'auto',
@ -29,28 +26,34 @@ export const generateNoise = ({
device,
/* wgsl */ `
fn random_with_seed(uv: vec2<f32>, seed: f32) -> f32 {
return fract(sin(dot(uv, vec2(12.9898 + seed, 78.233 + seed)))* 43758.5453123 + seed);
return fract(sin(dot(
uv,
vec2(
${appConfig.pipelines.common.noiseHashX} + seed,
${appConfig.pipelines.common.noiseHashY} + seed
)
)) * ${appConfig.pipelines.common.noiseHashMultiplier} + seed);
}
@fragment
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
return vec4(
random_with_seed(uv, 0),
random_with_seed(uv, 1),
random_with_seed(uv, 2),
random_with_seed(uv, 3),
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[0]}),
0.0,
0.0,
1.0,
);
}`
),
entryPoint: 'fragment',
targets: [
{
format: 'rgba16float',
format: appConfig.pipelines.common.noiseTextureFormat,
},
],
},
primitive: {
topology: 'triangle-strip',
topology: 'triangle-list',
},
});
@ -60,7 +63,7 @@ export const generateNoise = ({
height,
depthOrArrayLayers: 1,
},
format: 'rgba16float',
format: appConfig.pipelines.common.noiseTextureFormat,
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
});
@ -68,7 +71,7 @@ export const generateNoise = ({
colorAttachments: [
{
view: colorTexture.createView(),
clearValue: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 },
clearValue: appConfig.pipelines.common.noiseClearValue,
loadOp: 'clear',
storeOp: 'store',
},
@ -79,11 +82,15 @@ export const generateNoise = ({
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(pipeline);
passEncoder.setVertexBuffer(0, vertexBuffer);
passEncoder.draw(4, 1);
passEncoder.draw(
appConfig.pipelines.common.noiseDrawVertexCount,
appConfig.pipelines.common.noiseDrawInstanceCount
);
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
textureCache.set(cacheKey, colorTexture);
return colorTexture.createView();
return {
texture: colorTexture,
view: colorTexture.createView(),
};
};

View file

@ -1,63 +1,124 @@
import { vec2 } from 'gl-matrix';
import { CopyPipeline } from '../../pipelines/copy/copy-pipeline';
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../../pipelines/texture-formats';
interface ResizableTextureOptions {
clearValue?: GPUColor;
format?: GPUTextureFormat;
usage?: GPUTextureUsageFlags;
}
export interface PendingTextureResize {
copySize: GPUExtent3DStrict;
newSize: vec2;
newTexture: GPUTexture;
newTextureView: GPUTextureView;
oldTexture: GPUTexture;
}
export class ResizableTexture {
private texture: GPUTexture;
private textureView: GPUTextureView;
private size: vec2;
private readonly copyPipeline: CopyPipeline;
private readonly clearValue: GPUColor;
private readonly format: GPUTextureFormat;
private readonly usage: GPUTextureUsageFlags;
public constructor(
private readonly device: GPUDevice,
size: vec2
size: vec2,
{
clearValue = { r: 0, g: 0, b: 0, a: 0 },
format = TRAIL_SOURCE_TEXTURE_FORMAT,
usage = defaultTextureUsage,
}: ResizableTextureOptions = {}
) {
this.copyPipeline = new CopyPipeline(this.device);
this.size = size;
this.size = vec2.clone(size);
this.clearValue = clearValue;
this.format = format;
this.usage = usage;
this.texture = this.createTexture(size);
this.textureView = this.texture.createView();
}
public resize(size: vec2): void {
public prepareResize(size: vec2): PendingTextureResize | null {
if (vec2.equals(this.size, size)) {
return;
return null;
}
const newTexture = this.createTexture(size);
const newTextureView = newTexture.createView();
const copySize = {
width: Math.min(this.size[0], size[0]),
height: Math.min(this.size[1], size[1]),
};
const commandEncoder = this.device.createCommandEncoder();
this.copyPipeline.execute(
commandEncoder,
this.textureView,
return {
copySize,
newSize: vec2.clone(size),
newTexture,
newTextureView,
vec2.div(vec2.create(), this.size, size)
);
this.device.queue.submit([commandEncoder.finish()]);
this.texture.destroy();
oldTexture: this.texture,
};
}
this.size = size;
this.texture = newTexture;
this.textureView = newTextureView;
public encodeResize(
commandEncoder: GPUCommandEncoder,
resize: PendingTextureResize
): void {
const clearPass = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: resize.newTextureView,
clearValue: this.clearValue,
loadOp: 'clear',
storeOp: 'store',
},
],
});
clearPass.end();
commandEncoder.copyTextureToTexture(
{ texture: resize.oldTexture },
{ texture: resize.newTexture },
resize.copySize
);
}
public commitResize(resize: PendingTextureResize): void {
resize.oldTexture.destroy();
this.size = resize.newSize;
this.texture = resize.newTexture;
this.textureView = resize.newTextureView;
}
public getSize(): vec2 {
return vec2.clone(this.size);
}
public getTextureView(): GPUTextureView {
return this.textureView;
}
public getTexture(): GPUTexture {
return this.texture;
}
public destroy(): void {
this.texture.destroy();
this.copyPipeline.destroy();
}
private createTexture(size: vec2): GPUTexture {
return this.device.createTexture({
format: 'rgba16float',
format: this.format,
size: { width: size[0], height: size[1] },
usage:
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT,
usage: this.usage,
});
}
}
const defaultTextureUsage =
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_SRC |
GPUTextureUsage.COPY_DST;

View file

@ -10,20 +10,25 @@ export const smartCompile = (
code: concatenated,
});
module.getCompilationInfo().then((info) =>
info.messages.forEach((message) =>
module.getCompilationInfo().then((info) => {
if (info.messages.length === 0) {
return;
}
const lines = concatenated.split('\n');
info.messages.forEach((message) => {
const sourceLine = lines[message.lineNum - 1] ?? '';
const fullSource = import.meta.env.DEV ? `\n\nCode:\n${concatenated}\n` : '';
ErrorHandler.addError(
{
info: Severity.INFO,
warning: Severity.WARNING,
error: Severity.ERROR,
}[message.type],
`${message.message}\n${
concatenated.split('\n')[message.lineNum - 1]
}\n\nCode:\n${concatenated}\n`
)
)
);
`${message.message}\n${sourceLine}${fullSource}`
);
});
});
return module;
};