Rework garden simulation pipelines
This commit is contained in:
parent
c2efb33683
commit
018f8c9d4d
67 changed files with 6544 additions and 1727 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
38
src/utils/graphics/bind-group-cache.ts
Normal file
38
src/utils/graphics/bind-group-cache.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
38
src/utils/graphics/cached-buffer-write.test.ts
Normal file
38
src/utils/graphics/cached-buffer-write.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
src/utils/graphics/cached-buffer-write.ts
Normal file
46
src/utils/graphics/cached-buffer-write.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue