diff --git a/apps/typegpu-docs/src/components/ControlPanel.tsx b/apps/typegpu-docs/src/components/ControlPanel.tsx index 93c0f01217..942bc864ef 100644 --- a/apps/typegpu-docs/src/components/ControlPanel.tsx +++ b/apps/typegpu-docs/src/components/ControlPanel.tsx @@ -218,8 +218,21 @@ function ButtonRow({ label, onClick }: { label: string; onClick: () => void }) { ); } +function SectionRow({ label }: { label: string }) { + return ( +
+
+ {label} +
+
+
+ ); +} + function paramToControlRow(param: ExampleControlParam) { - return 'onSelectChange' in param ? ( + return param.isSection === true ? ( + + ) : 'onSelectChange' in param ? ( (param.onButtonClick as () => void)()); + controlsPanel.appendChild(button); + return; + } + + const controlRow = document.createElement('div'); + controlRow.style.display = 'contents'; + const labelDiv = document.createElement('div'); + labelDiv.innerText = label; + controlRow.appendChild(labelDiv); + + if ('onSliderChange' in param) { + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = `${param.min}`; + slider.max = `${param.max}`; + slider.step = `${(param.step as number | undefined) ?? 0.1}`; + slider.value = `${param.initial}`; + slider.addEventListener('input', () => { + (param.onSliderChange as (v: number) => void)(Number.parseFloat(slider.value)); + }); + controlRow.appendChild(slider); + } + + if ('onSelectChange' in param) { + const select = document.createElement('select'); + select.innerHTML = (param.options as string[]) + .map((option) => ``) + .join(''); + select.value = param.initial as string; + select.addEventListener('change', () => { + (param.onSelectChange as (v: string) => void)(select.value); + }); + controlRow.appendChild(select); + } + + if ('onVectorSliderChange' in param) { + const sliderContainer = document.createElement('div'); + sliderContainer.style.display = 'flex'; + sliderContainer.style.flexDirection = 'column'; + sliderContainer.style.gap = '0.2rem'; + + const currentValues = param.initial as d.v2f | d.v3f | d.v4f; + const min = param.min as d.v2f | d.v3f | d.v4f; + const max = param.max as d.v2f | d.v3f | d.v4f; + const step = param.step as d.v2f | d.v3f | d.v4f; + const length = min.length; + const labels = ['x', 'y', 'z', 'w']; + + for (let i = 0; i < length; i++) { + const row = document.createElement('div'); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '0.2rem'; + + const labelSpan = document.createElement('span'); + labelSpan.innerText = labels[i]; + + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = `${min[i]}`; + slider.max = `${max[i]}`; + slider.step = `${step[i] ?? 0.1}`; + slider.value = `${currentValues[i]}`; + + slider.addEventListener('input', () => { + currentValues[i] = Number.parseFloat(slider.value); + (param.onVectorSliderChange as (value: d.v2f | d.v3f | d.v4f) => void)(currentValues); + }); + + row.appendChild(labelSpan); + row.appendChild(slider); + sliderContainer.appendChild(row); + } + + controlRow.appendChild(sliderContainer); + } + + if ('onColorChange' in param) { + const input = document.createElement('input'); + input.type = 'color'; + const initial = (param.initial as d.v3f | undefined) ?? d.vec3f(0, 0, 0); + input.value = rgbToHex(initial); + input.addEventListener('input', () => { + (param.onColorChange as (v: d.v3f) => void)(hexToRgb(input.value)); + }); + controlRow.appendChild(input); + } + + if ('onToggleChange' in param) { + const toggle = document.createElement('input'); + toggle.type = 'checkbox'; + toggle.checked = (param.initial as boolean | undefined) ?? false; + toggle.addEventListener('change', () => { + (param.onToggleChange as (v: boolean) => void)(toggle.checked); + }); + controlRow.appendChild(toggle); + } + + if ('onTextChange' in param) { + const input = document.createElement('input'); + input.value = (param.initial as string | undefined) ?? ''; + input.addEventListener('input', () => { + (param.onTextChange as (v: string) => void)(input.value); + }); + controlRow.appendChild(input); + } + + controlsPanel.appendChild(controlRow); +} + // Create example controls for (const controls of Object.values(example)) { if (typeof controls === 'function') { continue; } - for (const [label, params] of Object.entries(controls as Record)) { - if ('onButtonClick' in params) { - const button = document.createElement('button'); - button.innerText = label; - button.style.gridColumn = 'span 2'; - button.addEventListener('click', () => params.onButtonClick()); - controlsPanel.appendChild(button); - } else { - const controlRow = document.createElement('div'); - controlRow.style.display = 'contents'; - const labelDiv = document.createElement('div'); - labelDiv.innerText = label; - controlRow.appendChild(labelDiv); - - if ('onSliderChange' in params) { - const slider = document.createElement('input'); - slider.type = 'range'; - slider.min = `${params.min}`; - slider.max = `${params.max}`; - slider.step = `${params.step ?? 0.1}`; - slider.value = `${params.initial}`; - slider.addEventListener('input', () => { - params.onSliderChange(Number.parseFloat(slider.value)); - }); - - controlRow.appendChild(slider); - params.onSliderChange(Number.parseFloat(slider.value)); - } - - if ('onSelectChange' in params) { - const select = document.createElement('select'); - select.innerHTML = params.options - .map((option) => ``) - .join(''); - select.value = params.initial; - - select.addEventListener('change', () => { - params.onSelectChange(select.value); - }); - - controlRow.appendChild(select); - params.onSelectChange(select.value); - } - - if ('onVectorSliderChange' in params) { - const sliderContainer = document.createElement('div'); - sliderContainer.style.display = 'flex'; - sliderContainer.style.flexDirection = 'column'; - sliderContainer.style.gap = '0.2rem'; - - const currentValues = params.initial; - const length = params.min.length; - const labels = ['x', 'y', 'z', 'w']; - - for (let i = 0; i < length; i++) { - const row = document.createElement('div'); - row.style.display = 'flex'; - row.style.alignItems = 'center'; - row.style.gap = '0.2rem'; - - const labelSpan = document.createElement('span'); - labelSpan.innerText = labels[i]; - - const slider = document.createElement('input'); - slider.type = 'range'; - slider.min = `${params.min[i]}`; - slider.max = `${params.max[i]}`; - slider.step = `${params.step[i] ?? 0.1}`; - slider.value = `${currentValues[i]}`; - - slider.addEventListener('input', () => { - currentValues[i] = Number.parseFloat(slider.value); - (params.onVectorSliderChange as (value: d.v2f | d.v3f | d.v4f) => void)(currentValues); - }); - - row.appendChild(labelSpan); - row.appendChild(slider); - sliderContainer.appendChild(row); - } - - (params.onVectorSliderChange as (value: d.v2f | d.v3f | d.v4f) => void)(currentValues); - controlRow.appendChild(sliderContainer); - } - - if ('onColorChange' in params) { - const input = document.createElement('input'); - input.type = 'color'; - - const initial = params.initial ?? [0, 0, 0]; - input.value = rgbToHex(initial); - - input.addEventListener('input', () => { - params.onColorChange(hexToRgb(input.value)); - }); - - params.onColorChange(initial); - controlRow.appendChild(input); - } - - if ('onToggleChange' in params) { - const toggle = document.createElement('input'); - toggle.type = 'checkbox'; - toggle.checked = params.initial ?? false; - - toggle.addEventListener('change', () => { - params.onToggleChange(toggle.checked); - }); - - controlRow.appendChild(toggle); - params.onToggleChange(toggle.checked); - } - - if ('onTextChange' in params) { - const input = document.createElement('input'); - input.value = params.initial ?? ''; - - input.addEventListener('input', () => { - params.onTextChange(input.value); - }); - - controlRow.appendChild(input); - params.onTextChange(input.value); - } - - controlsPanel.appendChild(controlRow); + for (const param of flattenControls(controls as Record)) { + if (isFlatSection(param)) { + appendSectionHeader(param.label); + continue; } + addControlToPanel(param); + initializeControlParam(param); } } -type SelectControlParam = { - onSelectChange: (newValue: string) => void; - initial: string; - options: string[]; -}; - -type ToggleControlParam = { - onToggleChange: (newValue: boolean) => void; - initial: boolean; -}; - -type SliderControlParam = { - onSliderChange: (newValue: number) => void; - initial: number; - min?: number; - max?: number; - step?: number; -}; - -type VectorSliderControlParam = { - onVectorSliderChange: (newValue: T) => void; - initial: T; - min: T; - max: T; - step: T; -}; - -type ColorPickerControlParam = { - onColorChange: (newValue: d.v3f) => void; - initial: d.v3f; -}; - -type ButtonControlParam = { - onButtonClick: (() => void) | (() => Promise); -}; - -type TextAreaControlParam = { - onTextChange: (newValue: string) => void; - initial: string; -}; - -type ExampleControlParam = - | SelectControlParam - | ToggleControlParam - | SliderControlParam - | ButtonControlParam - | TextAreaControlParam - | VectorSliderControlParam - | VectorSliderControlParam - | VectorSliderControlParam - | ColorPickerControlParam; - function hexToRgb(hex: string): d.v3f { return d.vec3f( Number.parseInt(hex.slice(1, 3), 16) / 255, diff --git a/apps/typegpu-docs/src/examples/common/defineControls.ts b/apps/typegpu-docs/src/examples/common/defineControls.ts index 700efc600a..958a64dd6f 100644 --- a/apps/typegpu-docs/src/examples/common/defineControls.ts +++ b/apps/typegpu-docs/src/examples/common/defineControls.ts @@ -41,22 +41,34 @@ type TextAreaControlParam = { onTextChange: (newValue: string) => void; }; +type ControlField = + | false // short-circuit controls + | SelectControlParam< + T extends readonly string[] | readonly number[] ? T : T extends string[] ? string[] : number[] + > + | ToggleControlParam + | SliderControlParam + | VectorSliderControlParam + | ColorPickerControlParam + | ButtonControlParam + | TextAreaControlParam; + +export type ControlSection = Record> = { + isSection: true; + controls: T; +}; + +export function section>(controls: { + [Key in keyof T]: ControlField; +}): ControlSection { + return { + isSection: true, + controls: controls as T, + }; +} + export function defineControls>(controls: { - [Key in keyof T]: - | false // short-circuit controls - | SelectControlParam< - T[Key] extends readonly string[] | readonly number[] - ? T[Key] - : T[Key] extends string[] - ? string[] - : number[] - > - | ToggleControlParam - | SliderControlParam - | VectorSliderControlParam - | ColorPickerControlParam - | ButtonControlParam - | TextAreaControlParam; + [Key in keyof T]: ControlField | ControlSection; }) { return controls; } diff --git a/apps/typegpu-docs/src/examples/common/flattenControls.ts b/apps/typegpu-docs/src/examples/common/flattenControls.ts new file mode 100644 index 0000000000..e196290087 --- /dev/null +++ b/apps/typegpu-docs/src/examples/common/flattenControls.ts @@ -0,0 +1,83 @@ +import type { d } from 'typegpu'; +import type { ControlSection } from './defineControls.ts'; + +export type FlatSectionParam = { + isSection: true; + label: string; +}; + +export type FlatLabeledControl = Record & { label: string }; + +export type FlatControlParam = FlatSectionParam | FlatLabeledControl; + +export function isControlSection(value: unknown): value is ControlSection { + return ( + typeof value === 'object' && + value !== null && + 'isSection' in value && + Boolean((value as ControlSection).isSection) && + 'controls' in value + ); +} + +export function isFlatSection(param: FlatControlParam): param is FlatSectionParam { + return 'isSection' in param && Boolean(param.isSection); +} + +/** + * Flattens `defineControls` / `section()` trees into a linear list for the control panel. + * Only recognizes explicit `section()` wrappers — plain nested objects are not sections. + */ +export function flattenControls(options: Record): FlatControlParam[] { + const result: FlatControlParam[] = []; + + for (const [label, value] of Object.entries(options)) { + if (!value) { + continue; + } + + if (isControlSection(value)) { + result.push({ label, isSection: true }); + result.push(...flattenControls(value.controls)); + continue; + } + + if (typeof value === 'object') { + result.push({ + ...(value as Record), + label, + }); + } + } + + return result; +} + +/** Eagerly apply each control's initial value. */ +export function initializeControlParam(param: FlatLabeledControl): void { + if ('onSelectChange' in param) { + (param.onSelectChange as (v: string) => void)(param.initial as string); + return; + } + if ('onToggleChange' in param) { + (param.onToggleChange as (v: boolean) => void)(param.initial as boolean); + return; + } + if ('onSliderChange' in param) { + (param.onSliderChange as (v: number) => void)(param.initial as number); + return; + } + if ('onVectorSliderChange' in param) { + (param.onVectorSliderChange as (v: d.v2f | d.v3f | d.v4f) => void)( + param.initial as d.v2f | d.v3f | d.v4f, + ); + return; + } + if ('onColorChange' in param) { + (param.onColorChange as (v: d.v3f) => void)(param.initial as d.v3f); + return; + } + if ('onTextChange' in param) { + (param.onTextChange as (v: string) => void)(param.initial as string); + } +} diff --git a/apps/typegpu-docs/src/examples/simulation/fire-text/config.ts b/apps/typegpu-docs/src/examples/simulation/fire-text/config.ts new file mode 100644 index 0000000000..c1be32d3c2 --- /dev/null +++ b/apps/typegpu-docs/src/examples/simulation/fire-text/config.ts @@ -0,0 +1,49 @@ +import { d } from 'typegpu'; + +export const defaults = { + textureSize: 512, + solverIterations: 50, + numParticles: 2500, + textInsidePressure: 1.0, + textStartTemperature: 1.0, + textOutlineWidth: 5.5, + brushRadius: 100, + softBrush: true, + buoyancy: 140, + timestep: 0.7, + tempPower: 8.0, + particleSize: 1.8, + densityDecay: 0.999, + tempDecay: 0.996, + vorticityStrength: 40.0, + thermalStrength: 50.0, + brushMode: 'Velocity Brush' as const, + renderMode: 'Fire' as const, + text: 'TypeGPU.', + cursorBlink: true, + maxParticles: 20000, +} as const; + +export const textureSizeOptions = ['128', '256', '512', '1024', '2048'] as const; +export const brushModes = ['Instant Brush', 'Constant Source Brush', 'Velocity Brush'] as const; +export const renderModes = ['Fire', 'Density', 'Velocity'] as const; + +export const Config = d.struct({ + time: d.f32, + dt: d.f32, + stampPos: d.vec2u, + velocity: d.vec2f, + buoyancy: d.f32, + radius: d.f32, + isSoft: d.u32, + isMouseDown: d.u32, + brushMode: d.u32, + textureSize: d.f32, + textInsidePressure: d.f32, + tempPower: d.f32, + particleSize: d.f32, + densityDecay: d.f32, + tempDecay: d.f32, + vorticityStrength: d.f32, + thermalStrength: d.f32, +}); diff --git a/apps/typegpu-docs/src/examples/simulation/fire-text/emitter.ts b/apps/typegpu-docs/src/examples/simulation/fire-text/emitter.ts new file mode 100644 index 0000000000..2fdcd89af4 --- /dev/null +++ b/apps/typegpu-docs/src/examples/simulation/fire-text/emitter.ts @@ -0,0 +1,60 @@ +import { + tgpu, + d, + std, + type SampledFlag, + type StorageFlag, + type TgpuBindGroup, + type TgpuRoot, + type TgpuTexture, + type TgpuUniform, +} from 'typegpu'; +import { Config } from './config.ts'; + +type R32Texture = TgpuTexture & SampledFlag & StorageFlag; + +export const constantSourceLayout = tgpu.bindGroupLayout({ + tex: { storageTexture: d.textureStorage2d('r32float', 'read-write') }, +}); + +export function createStampPipeline(root: TgpuRoot, configUniform: TgpuUniform) { + return root.createGuardedComputePipeline((x, y) => { + 'use gpu'; + const pos = configUniform.$.stampPos; + const radius = configUniform.$.radius; + const softBrush = configUniform.$.isSoft; + + const dx = d.i32(x) - d.i32(radius); + const dy = d.i32(y) - d.i32(radius); + const px = d.i32(pos.x) + dx; + const py = d.i32(pos.y) + dy; + + const dist = std.length(d.vec2f(d.f32(dx), d.f32(dy))); + const fRadius = d.f32(radius); + const texSize = d.i32(configUniform.$.textureSize); + + if (px >= 0 && px < texSize && py >= 0 && py < texSize) { + if (dist <= fRadius) { + let weight = d.f32(1.0); + if (softBrush === 1) { + weight = d.f32(1.0) - std.smoothstep(fRadius * 0.05, fRadius, dist); + } + const old = std.textureLoad(constantSourceLayout.$.tex, d.vec2u(d.u32(px), d.u32(py))).x; + std.textureStore( + constantSourceLayout.$.tex, + d.vec2u(d.u32(px), d.u32(py)), + d.vec4f(std.max(old, weight), 0.0, 0.0, 0.0), + ); + } + } + }); +} + +export function createEmitterBindGroup( + root: TgpuRoot, + resources: { constantSourceGrid: R32Texture }, +): TgpuBindGroup { + return root.createBindGroup(constantSourceLayout, { + tex: resources.constantSourceGrid, + }); +} diff --git a/apps/typegpu-docs/src/examples/simulation/fire-text/fluid.ts b/apps/typegpu-docs/src/examples/simulation/fire-text/fluid.ts new file mode 100644 index 0000000000..bade4783be --- /dev/null +++ b/apps/typegpu-docs/src/examples/simulation/fire-text/fluid.ts @@ -0,0 +1,490 @@ +import { + tgpu, + d, + std, + type SampledFlag, + type StorageFlag, + type TgpuBindGroup, + type TgpuRoot, + type TgpuSampler, + type TgpuTexture, + type TgpuUniform, +} from 'typegpu'; +import { perlin3d } from '@typegpu/noise'; +import { Config, defaults } from './config.ts'; + +type Rgba16Texture = TgpuTexture & SampledFlag & StorageFlag; +type R32Texture = TgpuTexture & SampledFlag & StorageFlag; +type NoiseCache = ReturnType; + +export const smokeLayout = tgpu.bindGroupLayout({ + linearSampler: { sampler: 'filtering' }, + nearestSampler: { sampler: 'filtering' }, + inTex: { texture: d.texture2d(d.f32) }, + outTex: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') }, + sourceTex: { storageTexture: d.textureStorage2d('r32float', 'read-only') }, + textSourceTex: { storageTexture: d.textureStorage2d('r32float', 'read-only') }, +}); + +export const divergenceLayout = tgpu.bindGroupLayout({ + divTex: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') }, + textFillTex: { texture: d.texture2d(d.f32), sampleType: 'unfilterable-float' }, +}); + +export const pressureLayout = tgpu.bindGroupLayout({ + nearestSampler: { sampler: 'filtering' }, + inTex: { storageTexture: d.textureStorage2d('r32float', 'read-only') }, + outTex: { storageTexture: d.textureStorage2d('r32float', 'write-only') }, + divTex: { texture: d.texture2d(d.f32) }, +}); + +export const gradientLayout = tgpu.bindGroupLayout({ + nearestSampler: { sampler: 'filtering' }, + inSmokeTex: { texture: d.texture2d(d.f32) }, + outSmokeTex: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') }, + pressureTex: { storageTexture: d.textureStorage2d('r32float', 'read-only') }, +}); + +export const clearPressureLayout = tgpu.bindGroupLayout({ + tex: { storageTexture: d.textureStorage2d('r32float', 'write-only') }, +}); + +export function createFluidPipelines( + root: TgpuRoot, + configUniform: TgpuUniform, + noiseCache: NoiseCache, +) { + const advection = root.createGuardedComputePipeline((x, y) => { + 'use gpu'; + const dt = configUniform.$.dt; + + const size = d.vec2f(std.textureDimensions(smokeLayout.$.inTex)); + const uv = (d.vec2f(x, y) + 0.5) / size; + + const thisState = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv, + 0.0, + ); + + const currentFlow = thisState.xy; + const oldUv = uv - (currentFlow * dt) / size; + + let newState = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.linearSampler, + oldUv, + 0.0, + ); + + const brushSource = std.textureLoad(smokeLayout.$.sourceTex, d.vec2u(x, y)).x; + const textSource = std.textureLoad(smokeLayout.$.textSourceTex, d.vec2u(x, y)).x; + const source = std.max(brushSource, textSource); + const heat = std.max(brushSource, textSource * defaults.textStartTemperature); + if (source > 0.0) { + newState.z = std.max(newState.z, source); + newState.w = std.max(newState.w, heat); + } + + const isDown = configUniform.$.isMouseDown; + if (isDown === 1) { + const bMode = configUniform.$.brushMode; + if (bMode !== 1) { + const pos = configUniform.$.stampPos; + const radius = configUniform.$.radius; + const softBrush = configUniform.$.isSoft; + + const dist = std.distance(d.vec2f(x, y), d.vec2f(pos)); + const fRadius = d.f32(radius); + + if (dist <= fRadius) { + let weight = d.f32(1.0); + if (softBrush === 1) { + weight = d.f32(1.0) - std.smoothstep(fRadius * 0.1, fRadius, dist); + } + + if (bMode === 0 || bMode === 2) { + newState.z = std.max(newState.z, weight); + newState.w = std.max(newState.w, weight); + const v = configUniform.$.velocity; + newState.x += v.x * weight * 0.15; + newState.y += v.y * weight * 0.15; + } + } + } + } + + newState.z *= configUniform.$.densityDecay; + newState.w *= configUniform.$.tempDecay; + + std.textureStore(smokeLayout.$.outTex, d.vec2u(x, y), newState); + }); + + const divergence = root.createGuardedComputePipeline((x, y) => { + 'use gpu'; + + const size = d.vec2i(std.textureDimensions(smokeLayout.$.inTex)); + const xi = d.i32(x); + const yi = d.i32(y); + + const xL = std.max(0, xi - 1); + const xR = std.min(size.x - 1, xi + 1); + const yT = std.max(0, yi - 1); + const yB = std.min(size.y - 1, yi + 1); + + const vL = std.textureLoad(smokeLayout.$.inTex, d.vec2i(xL, yi), 0).xy; + const vR = std.textureLoad(smokeLayout.$.inTex, d.vec2i(xR, yi), 0).xy; + const vT = std.textureLoad(smokeLayout.$.inTex, d.vec2i(xi, yT), 0).xy; + const vB = std.textureLoad(smokeLayout.$.inTex, d.vec2i(xi, yB), 0).xy; + + let div = 0.5 * (vR.x - vL.x + (vB.y - vT.y)); + + // volume source inside the letters: lowering the Poisson RHS here raises + // the solved pressure, so the projection pushes fluid out through the outline + const fill = std.textureLoad(divergenceLayout.$.textFillTex, d.vec2i(xi, yi), 0).x; + div -= fill * configUniform.$.textInsidePressure; + + std.textureStore(divergenceLayout.$.divTex, d.vec2u(x, y), d.vec4f(div, 0.0, 0.0, 1.0)); + }); + + const pressureSolverJacobi = root.createGuardedComputePipeline((x, y) => { + 'use gpu'; + const size = d.vec2i(std.textureDimensions(pressureLayout.$.inTex)); + const xi = d.i32(x); + const yi = d.i32(y); + + const xL = std.max(0, xi - 1); + const xR = std.min(size.x - 1, xi + 1); + const yT = std.max(0, yi - 1); + const yB = std.min(size.y - 1, yi + 1); + + const pL = std.textureLoad(pressureLayout.$.inTex, d.vec2i(xL, yi)).x; + const pR = std.textureLoad(pressureLayout.$.inTex, d.vec2i(xR, yi)).x; + const pT = std.textureLoad(pressureLayout.$.inTex, d.vec2i(xi, yT)).x; + const pB = std.textureLoad(pressureLayout.$.inTex, d.vec2i(xi, yB)).x; + + const uv = (d.vec2f(x, y) + 0.5) / d.vec2f(size); + const div = std.textureSampleLevel( + pressureLayout.$.divTex, + pressureLayout.$.nearestSampler, + uv, + 0.0, + ).x; + + const newPressure = (pL + pR + pT + pB - div) * 0.25; + + std.textureStore(pressureLayout.$.outTex, d.vec2u(x, y), d.vec4f(newPressure, 0.0, 0.0, 1.0)); + }); + + const gradientSubtraction = root.createGuardedComputePipeline((x, y) => { + 'use gpu'; + const size = d.vec2i(std.textureDimensions(gradientLayout.$.pressureTex)); + const xi = d.i32(x); + const yi = d.i32(y); + + const xL = std.max(0, xi - 1); + const xR = std.min(size.x - 1, xi + 1); + const yT = std.max(0, yi - 1); + const yB = std.min(size.y - 1, yi + 1); + + const pL = std.textureLoad(gradientLayout.$.pressureTex, d.vec2i(xL, yi)).x; + const pR = std.textureLoad(gradientLayout.$.pressureTex, d.vec2i(xR, yi)).x; + const pT = std.textureLoad(gradientLayout.$.pressureTex, d.vec2i(xi, yT)).x; + const pB = std.textureLoad(gradientLayout.$.pressureTex, d.vec2i(xi, yB)).x; + + const grad = d.vec2f(pR - pL, pB - pT) * 0.5; + + const uv = (d.vec2f(x, y) + 0.5) / d.vec2f(size); + const oldSmoke = std.textureSampleLevel( + gradientLayout.$.inSmokeTex, + gradientLayout.$.nearestSampler, + uv, + 0.0, + ); + + let newVel = oldSmoke.xy - grad; + + if (x === 0 || xi === size.x - 1) { + newVel.x = 0.0; + } + if (y === 0 || yi === size.y - 1) { + newVel.y = 0.0; + } + + std.textureStore( + gradientLayout.$.outSmokeTex, + d.vec2u(x, y), + d.vec4f(newVel, oldSmoke.z, oldSmoke.w), + ); + }); + + const vorticityConfinement = root + .pipe(noiseCache.inject()) + .createGuardedComputePipeline((x, y) => { + 'use gpu'; + const size = d.vec2f(std.textureDimensions(smokeLayout.$.inTex)); + const texelSize = 1.0 / size; + const uv = (d.vec2f(x, y) + 0.5) * texelSize; + + if ( + x <= 2 || + x >= std.textureDimensions(smokeLayout.$.inTex).x - 3 || + y <= 2 || + y >= std.textureDimensions(smokeLayout.$.inTex).y - 3 + ) { + const state = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv, + 0.0, + ); + std.textureStore(smokeLayout.$.outTex, d.vec2u(x, y), state); + return; + } + + const vC = std.textureSampleLevel(smokeLayout.$.inTex, smokeLayout.$.nearestSampler, uv, 0.0); + const vL = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv - d.vec2f(texelSize.x, 0.0), + 0.0, + ); + const vR = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv + d.vec2f(texelSize.x, 0.0), + 0.0, + ); + const vT = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv - d.vec2f(0.0, texelSize.y), + 0.0, + ); + const vB = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv + d.vec2f(0.0, texelSize.y), + 0.0, + ); + + const vLL = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv - d.vec2f(2.0 * texelSize.x, 0.0), + 0.0, + ).xy; + const vRR = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv + d.vec2f(2.0 * texelSize.x, 0.0), + 0.0, + ).xy; + const vTT = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv - d.vec2f(0.0, 2.0 * texelSize.y), + 0.0, + ).xy; + const vBB = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv + d.vec2f(0.0, 2.0 * texelSize.y), + 0.0, + ).xy; + + const vLT = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv + d.vec2f(-texelSize.x, -texelSize.y), + 0.0, + ).xy; + const vLB = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv + d.vec2f(-texelSize.x, texelSize.y), + 0.0, + ).xy; + const vRT = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv + d.vec2f(texelSize.x, -texelSize.y), + 0.0, + ).xy; + const vRB = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv + d.vec2f(texelSize.x, texelSize.y), + 0.0, + ).xy; + + const curlC = 0.5 * (vR.y - vL.y - (vB.x - vT.x)); + const curlL = 0.5 * (vC.y - vLL.y - (vLB.x - vLT.x)); + const curlR = 0.5 * (vRR.y - vC.y - (vRB.x - vRT.x)); + const curlT = 0.5 * (vRT.y - vLT.y - (vC.x - vTT.x)); + const curlB = 0.5 * (vRB.y - vLB.y - (vBB.x - vC.x)); + + const etaX = 0.5 * (std.abs(curlR) - std.abs(curlL)); + const etaY = 0.5 * (std.abs(curlB) - std.abs(curlT)); + + let force = d.vec2f(0.0); + const etaLen = std.length(d.vec2f(etaX, etaY)); + if (etaLen > 0.0001) { + const nx = etaX / etaLen; + const ny = etaY / etaLen; + force = d.vec2f(ny * curlC, -nx * curlC); + } + + const gradTx = 0.5 * (vR.w - vL.w); + const gradTy = 0.5 * (vB.w - vT.w); + const gradTLen = std.length(d.vec2f(gradTx, gradTy)); + let thermalForce = d.vec2f(0.0); + if (gradTLen > 0.0001) { + const nx = gradTx / gradTLen; + const ny = gradTy / gradTLen; + thermalForce = d.vec2f(ny * curlC, -nx * curlC); + } + + const vorticityStrength = configUniform.$.vorticityStrength; + const thermalStrength = configUniform.$.thermalStrength; + const dt = configUniform.$.dt; + + const state = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.nearestSampler, + uv, + 0.0, + ); + + const buoyancyForce = d.vec2f(0.0, -configUniform.$.buoyancy * state.w); + + const time = configUniform.$.time; + const noiseVal = perlin3d.sample(d.vec3f(uv.x * size.x * 0.02, uv.y * size.y * 0.02, time)); + const windForce = d.vec2f(noiseVal * 40.0 * state.w, 0.0); + + let newVel = + state.xy + + force * vorticityStrength * dt + + thermalForce * thermalStrength * dt + + buoyancyForce * dt + + windForce * dt; + + const maxVel = d.f32(1000.0); + const velLen = std.length(newVel); + if (velLen > maxVel) { + newVel = (newVel / velLen) * maxVel; + } + + if (x === 0 || x === std.textureDimensions(smokeLayout.$.inTex).x - 1) newVel.x = 0.0; + if (y === 0 || y === std.textureDimensions(smokeLayout.$.inTex).y - 1) newVel.y = 0.0; + + std.textureStore(smokeLayout.$.outTex, d.vec2u(x, y), d.vec4f(newVel, state.z, state.w)); + }); + + const clearPressure = root.createGuardedComputePipeline((x, y) => { + 'use gpu'; + std.textureStore(clearPressureLayout.$.tex, d.vec2u(x, y), d.vec4f(0.0)); + }); + + return { + advection, + vorticityConfinement, + divergence, + pressureSolverJacobi, + gradientSubtraction, + clearPressure, + }; +} + +export function createFluidBindGroups( + root: TgpuRoot, + resources: { + linearSampler: TgpuSampler; + nearestSampler: TgpuSampler; + smokeGrid: [Rgba16Texture, Rgba16Texture]; + constantSourceGrid: R32Texture; + textSourceGrid: R32Texture; + textFillGrid: R32Texture; + pressureGrid: [R32Texture, R32Texture]; + divergenceGrid: Rgba16Texture; + }, +) { + const { + linearSampler, + nearestSampler, + smokeGrid, + constantSourceGrid, + textSourceGrid, + textFillGrid, + pressureGrid, + divergenceGrid, + } = resources; + + const smokeBgs: [TgpuBindGroup, TgpuBindGroup] = [ + root.createBindGroup(smokeLayout, { + linearSampler, + nearestSampler, + inTex: smokeGrid[0], + outTex: smokeGrid[1], + sourceTex: constantSourceGrid, + textSourceTex: textSourceGrid, + }), + root.createBindGroup(smokeLayout, { + linearSampler, + nearestSampler, + inTex: smokeGrid[1], + outTex: smokeGrid[0], + sourceTex: constantSourceGrid, + textSourceTex: textSourceGrid, + }), + ]; + + const divergenceBg = root.createBindGroup(divergenceLayout, { + divTex: divergenceGrid, + textFillTex: textFillGrid, + }); + + const pressureBgs: [TgpuBindGroup, TgpuBindGroup] = [ + root.createBindGroup(pressureLayout, { + nearestSampler, + inTex: pressureGrid[0], + outTex: pressureGrid[1], + divTex: divergenceGrid, + }), + root.createBindGroup(pressureLayout, { + nearestSampler, + inTex: pressureGrid[1], + outTex: pressureGrid[0], + divTex: divergenceGrid, + }), + ]; + + const gradientBgs: [TgpuBindGroup, TgpuBindGroup] = [ + root.createBindGroup(gradientLayout, { + nearestSampler, + inSmokeTex: smokeGrid[0], + outSmokeTex: smokeGrid[1], + pressureTex: pressureGrid[0], + }), + root.createBindGroup(gradientLayout, { + nearestSampler, + inSmokeTex: smokeGrid[1], + outSmokeTex: smokeGrid[0], + pressureTex: pressureGrid[0], + }), + ]; + + const clearPressureBgs: [TgpuBindGroup, TgpuBindGroup] = [ + root.createBindGroup(clearPressureLayout, { tex: pressureGrid[0] }), + root.createBindGroup(clearPressureLayout, { tex: pressureGrid[1] }), + ]; + + return { + smokeBgs, + divergenceBg, + pressureBgs, + gradientBgs, + clearPressureBgs, + }; +} diff --git a/apps/typegpu-docs/src/examples/simulation/fire-text/index.html b/apps/typegpu-docs/src/examples/simulation/fire-text/index.html new file mode 100644 index 0000000000..bdac978618 --- /dev/null +++ b/apps/typegpu-docs/src/examples/simulation/fire-text/index.html @@ -0,0 +1,44 @@ + +
+

Controls (click to dismiss)

+ +

+ This is an interactive sandbox.
+ Feel free to tweak the simulation settings and brushes to create various VFX. +

+ +

Click & Drag: Paint with fire

+

+ Typing: Click on the canvas and type, or use the text input in the control panel +

+
+ diff --git a/apps/typegpu-docs/src/examples/simulation/fire-text/index.ts b/apps/typegpu-docs/src/examples/simulation/fire-text/index.ts new file mode 100644 index 0000000000..1315e4b831 --- /dev/null +++ b/apps/typegpu-docs/src/examples/simulation/fire-text/index.ts @@ -0,0 +1,598 @@ +import { tgpu, d, type SampledFlag, type StorageFlag, type TgpuTexture } from 'typegpu'; +import { perlin3d } from '@typegpu/noise'; +import { brushModes, Config, defaults, renderModes, textureSizeOptions } from './config.ts'; +import { createFluidBindGroups, createFluidPipelines } from './fluid.ts'; +import { createEmitterBindGroup, createStampPipeline } from './emitter.ts'; +import { createParticleRenderBindGroup, createParticles } from './particles.ts'; +import { createDisplayBindGroups, createRenderPipelines } from './render.ts'; +import { createTextMask } from './text.ts'; +import { defineControls, section } from '../../common/defineControls.ts'; + +type Rgba16Texture = TgpuTexture & SampledFlag & StorageFlag; +type R32Texture = TgpuTexture & SampledFlag & StorageFlag; + +// #region Setup + +const root = await tgpu.init(); +const canvas = document.querySelector('canvas') as HTMLCanvasElement; +const context = root.configureContext({ canvas, alphaMode: 'premultiplied' }); + +const configUniform = root.createUniform(Config); +const noiseCache = perlin3d.staticCache({ root, size: d.vec3u(32, 32, 32) }); + +function createRgba16StorageSampledTexture(size: number, name: string): Rgba16Texture { + return root + .createTexture({ size: [size, size], format: 'rgba16float' }) + .$usage('storage', 'sampled') + .$name(name); +} + +function createR32StorageSampledTexture(size: number, name: string): R32Texture { + return root + .createTexture({ size: [size, size], format: 'r32float' }) + .$usage('storage', 'sampled') + .$name(name); +} + +let smokeGrid: [Rgba16Texture, Rgba16Texture]; +let constantSourceGrid: R32Texture; +let textSourceGrid: R32Texture; +let textFillGrid: R32Texture; +let pressureGrid: [R32Texture, R32Texture]; +let divergenceGrid: Rgba16Texture; + +function recreateGridTextures(size: number) { + if (smokeGrid) { + smokeGrid[0].destroy(); + smokeGrid[1].destroy(); + constantSourceGrid.destroy(); + textSourceGrid.destroy(); + textFillGrid.destroy(); + pressureGrid[0].destroy(); + pressureGrid[1].destroy(); + divergenceGrid.destroy(); + } + + smokeGrid = [ + createRgba16StorageSampledTexture(size, 'smoke0'), + createRgba16StorageSampledTexture(size, 'smoke1'), + ]; + + constantSourceGrid = createR32StorageSampledTexture(size, 'constantSource'); + textSourceGrid = createR32StorageSampledTexture(size, 'textSource'); + textFillGrid = createR32StorageSampledTexture(size, 'textFill'); + + pressureGrid = [ + createR32StorageSampledTexture(size, 'pressure0'), + createR32StorageSampledTexture(size, 'pressure1'), + ]; + + divergenceGrid = createRgba16StorageSampledTexture(size, 'divergence'); +} + +const linearSampler = root.createSampler({ + addressModeU: 'clamp-to-edge', + addressModeV: 'clamp-to-edge', + magFilter: 'linear', + minFilter: 'linear', +}); + +const nearestSampler = root.createSampler({ + addressModeU: 'clamp-to-edge', + addressModeV: 'clamp-to-edge', + magFilter: 'nearest', + minFilter: 'nearest', +}); + +// #endregion + +// #region Pipelines (created once) + +const fluid = createFluidPipelines(root, configUniform, noiseCache); +const stampConstant = createStampPipeline(root, configUniform); +const particles = createParticles(root, configUniform); +const render = createRenderPipelines(root, configUniform); + +// #endregion + +// #region Bind groups (rebuilt on resize/clear) + +let fluidBgs: ReturnType; +let stampSourceBg: ReturnType; +let particleRenderBg: ReturnType; +let displayBgs: ReturnType; + +function rebuildBindGroups() { + fluidBgs = createFluidBindGroups(root, { + linearSampler, + nearestSampler, + smokeGrid, + constantSourceGrid, + textSourceGrid, + textFillGrid, + pressureGrid, + divergenceGrid, + }); + + stampSourceBg = createEmitterBindGroup(root, { constantSourceGrid }); + + particleRenderBg = createParticleRenderBindGroup(root, { + particleBuffer: particles.particleBuffer, + textSourceGrid, + }); + + displayBgs = createDisplayBindGroups(root, { + linearSampler, + smokeGrid, + }); +} + +// #endregion + +// #region Runtime state + +let even = 0; + +let isMouseDown = false; +let mouseTexX = -1000; +let mouseTexY = -1000; +let prevMouseTexX = -1000; +let prevMouseTexY = -1000; + +let currentTextureSize: number = defaults.textureSize; +let solverIterations: number = defaults.solverIterations; +let numParticles: number = defaults.numParticles; +let textInsidePressure: number = defaults.textInsidePressure; +let brushMode: number = brushModes.indexOf(defaults.brushMode); +let renderMode: number = renderModes.indexOf(defaults.renderMode); +let buoyancy: number = defaults.buoyancy; +let radius: number = defaults.brushRadius; +let speed: number = defaults.timestep; +let isSoft: boolean = defaults.softBrush; +let tempPower: number = defaults.tempPower; +let particleSize: number = defaults.particleSize; +let densityDecay: number = defaults.densityDecay; +let tempDecay: number = defaults.tempDecay; +let vorticityStrength: number = defaults.vorticityStrength; +let thermalStrength: number = defaults.thermalStrength; + +recreateGridTextures(defaults.textureSize); +rebuildBindGroups(); + +const textMask = createTextMask({ + getTextSourceGrid: () => textSourceGrid, + getTextFillGrid: () => textFillGrid, + getTextureSize: () => currentTextureSize, + initialText: defaults.text, +}); +textMask.start(); + +function updateTextureSize(newSize: number) { + currentTextureSize = newSize; + recreateGridTextures(newSize); + rebuildBindGroups(); + textMask.setTextureSize(newSize); +} + +// #endregion + +// #region Pointer input + +canvas.style.touchAction = 'none'; + +function canvasToTex(e: PointerEvent | MouseEvent) { + const r = canvas.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) { + return; + } + const u = (e.clientX - r.left) / r.width; + const v = (e.clientY - r.top) / r.height; + mouseTexX = Math.floor(u * currentTextureSize); + mouseTexY = Math.floor(v * currentTextureSize); +} + +function onPointerDown(e: PointerEvent) { + isMouseDown = true; + try { + canvas.setPointerCapture(e.pointerId); + } catch { + // ignore + } + canvasToTex(e); + prevMouseTexX = mouseTexX; + prevMouseTexY = mouseTexY; +} + +function onPointerMove(e: PointerEvent) { + if (isMouseDown) { + canvasToTex(e); + } +} + +function onPointerUp(e: PointerEvent) { + isMouseDown = false; + try { + if (canvas.hasPointerCapture(e.pointerId)) { + canvas.releasePointerCapture(e.pointerId); + } + } catch { + // ignore + } +} + +function onPointerCancel(e: PointerEvent) { + isMouseDown = false; + try { + if (canvas.hasPointerCapture(e.pointerId)) { + canvas.releasePointerCapture(e.pointerId); + } + } catch { + // ignore + } +} + +canvas.addEventListener('pointerdown', onPointerDown); +canvas.addEventListener('pointermove', onPointerMove); +canvas.addEventListener('pointerup', onPointerUp); +canvas.addEventListener('pointercancel', onPointerCancel); + +// #endregion + +// #region Simulation & render loop + +function callSimulate(encoder: GPUCommandEncoder) { + even = 1 - even; + + let velocity = d.vec2f(0); + if (brushMode === 2 && isMouseDown) { + let dx = mouseTexX - prevMouseTexX; + let dy = mouseTexY - prevMouseTexY; + + // Clamp dx to [-3, 3] so max velocity (90) is naturally reached on fast swipes, + // while slow movement (e.g. 0.3px) produces small velocity (9). + dx = Math.max(-3, Math.min(3, dx)); + dy = Math.max(-3, Math.min(3, dy)); + + velocity = d.vec2f(dx * 30, dy * 30); + } + + prevMouseTexX = mouseTexX; + prevMouseTexY = mouseTexY; + + configUniform.patch({ + time: performance.now() / 1000, + dt: speed / 60, + stampPos: d.vec2u(mouseTexX, mouseTexY), + velocity: velocity, + buoyancy: buoyancy, + radius: radius, + isSoft: isSoft ? 1 : 0, + isMouseDown: isMouseDown ? 1 : 0, + brushMode: brushMode, + textureSize: currentTextureSize, + textInsidePressure: textInsidePressure, + tempPower: tempPower, + particleSize: particleSize, + densityDecay: densityDecay, + tempDecay: tempDecay, + vorticityStrength: vorticityStrength, + thermalStrength: thermalStrength, + }); + + fluid.advection + .with(fluidBgs.smokeBgs[even]) + .with(encoder) + .dispatchThreads(currentTextureSize, currentTextureSize); + + if (isMouseDown && brushMode === 1) { + stampConstant + .with(stampSourceBg) + .with(encoder) + .dispatchThreads(radius * 2 + 1, radius * 2 + 1); + } + + fluid.vorticityConfinement + .with(fluidBgs.smokeBgs[1 - even]) + .with(encoder) + .dispatchThreads(currentTextureSize, currentTextureSize); + + fluid.divergence + .with(fluidBgs.divergenceBg) + .with(fluidBgs.smokeBgs[even]) + .with(encoder) + .dispatchThreads(currentTextureSize, currentTextureSize); + + fluid.clearPressure + .with(fluidBgs.clearPressureBgs[0]) + .with(encoder) + .dispatchThreads(currentTextureSize, currentTextureSize); + fluid.clearPressure + .with(fluidBgs.clearPressureBgs[1]) + .with(encoder) + .dispatchThreads(currentTextureSize, currentTextureSize); + + let pEven = 0; + const totalIterations = solverIterations * 2; + for (let i = 0; i < totalIterations; i++) { + fluid.pressureSolverJacobi + .with(fluidBgs.pressureBgs[pEven]) + .with(encoder) + .dispatchThreads(currentTextureSize, currentTextureSize); + pEven = 1 - pEven; + } + + fluid.gradientSubtraction + .with(fluidBgs.gradientBgs[even]) + .with(encoder) + .dispatchThreads(currentTextureSize, currentTextureSize); + + particles.updateParticles + .with(fluidBgs.smokeBgs[even]) + .with(particles.particleComputeBg) + .with(encoder) + .dispatchThreads(numParticles); +} + +function resizeCanvas() { + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const targetWidth = Math.max(1, Math.floor(rect.width * dpr)); + const targetHeight = Math.max(1, Math.floor(rect.height * dpr)); + + if (canvas.width !== targetWidth || canvas.height !== targetHeight) { + canvas.width = targetWidth; + canvas.height = targetHeight; + } +} + +const resizeObserver = new ResizeObserver(() => resizeCanvas()); +resizeObserver.observe(canvas); +resizeCanvas(); + +let animationFrameId: number; + +function frame() { + if (canvas.width === 0 || canvas.height === 0) { + animationFrameId = requestAnimationFrame(frame); + return; + } + + const encoder = root.device.createCommandEncoder(); + callSimulate(encoder); + + const activePipeline = + renderMode === 1 + ? render.densityPipeline + : renderMode === 2 + ? render.velocityPipeline + : render.firePipeline; + + activePipeline + .withColorAttachment({ view: context }) + .with(displayBgs[even]) + .with(encoder) + .draw(3); + + particles.particlePipeline + .with(particleRenderBg) + .withColorAttachment({ color: { view: context, loadOp: 'load' } }) + .with(encoder) + .draw(6, numParticles); + + root.device.queue.submit([encoder.finish()]); + + animationFrameId = requestAnimationFrame(frame); +} + +animationFrameId = requestAnimationFrame(frame); + +// #endregion + +// #region Example controls and cleanup + +export const controls = defineControls({ + 'Text & Actions': section({ + Text: { + initial: defaults.text, + onTextChange: (val) => { + textMask.setText(val); + }, + }, + + 'Blink Cursor': { + initial: defaults.cursorBlink, + onToggleChange: (val) => { + textMask.setCursorBlink(val); + }, + }, + + 'Texture Size': { + initial: String(defaults.textureSize) as (typeof textureSizeOptions)[number], + options: textureSizeOptions, + onSelectChange: (val) => { + updateTextureSize(Number(val)); + }, + }, + + 'Clear All Grids': { + onButtonClick: () => { + recreateGridTextures(currentTextureSize); + rebuildBindGroups(); + particles.particleBuffer.write(new Float32Array(particles.MAX_PARTICLES * 6).buffer); + textMask.uploadMask(); + }, + }, + }), + + 'Brush Settings': section({ + 'Brush Mode': { + initial: defaults.brushMode, + options: brushModes, + onSelectChange: (newMode) => { + brushMode = brushModes.indexOf(newMode); + }, + }, + + 'Brush Radius': { + initial: defaults.brushRadius, + min: 1, + max: 200, + step: 1, + onSliderChange: (val) => { + radius = val; + }, + }, + + 'Soft Brush': { + initial: defaults.softBrush, + onToggleChange: (val) => { + isSoft = val; + }, + }, + }), + + 'Rendering & Visuals': section({ + 'Render Mode': { + initial: defaults.renderMode, + options: renderModes, + onSelectChange: (newMode) => { + renderMode = renderModes.indexOf(newMode); + }, + }, + + 'Flame Color Contrast': { + initial: defaults.tempPower, + min: 0.5, + max: 10.0, + step: 0.1, + onSliderChange: (val) => { + tempPower = val; + }, + }, + + 'Particle Count': { + initial: defaults.numParticles, + min: 100, + max: 10000, + step: 100, + onSliderChange: (val) => { + numParticles = val; + }, + }, + + 'Particle Size': { + initial: defaults.particleSize, + min: 0.1, + max: 10.0, + step: 0.1, + onSliderChange: (val) => { + particleSize = val; + }, + }, + }), + + 'Simulation & Physics': section({ + 'Timestep (dt)': { + initial: defaults.timestep, + min: 0, + max: 3, + step: 0.1, + onSliderChange: (val) => { + speed = val; + }, + }, + + 'Solver Iterations': { + initial: defaults.solverIterations, + min: 1, + max: 300, + step: 1, + onSliderChange: (val) => { + solverIterations = val; + }, + }, + + Buoyancy: { + initial: defaults.buoyancy, + min: 0, + max: 250, + step: 1, + onSliderChange: (val) => { + buoyancy = val; + }, + }, + + 'Vorticity Confinement': { + initial: defaults.vorticityStrength, + min: 0.0, + max: 150.0, + step: 1.0, + onSliderChange: (val) => { + vorticityStrength = val; + }, + }, + + 'Thermal Confinement': { + initial: defaults.thermalStrength, + min: 0.0, + max: 150.0, + step: 1.0, + onSliderChange: (val) => { + thermalStrength = val; + }, + }, + + 'Pressure Inside Text': { + initial: defaults.textInsidePressure, + min: -10, + max: 10, + step: 0.1, + onSliderChange: (val) => { + textInsidePressure = val; + }, + }, + + 'Density Retention': { + initial: defaults.densityDecay, + min: 0.9, + max: 1.0, + step: 0.0001, + onSliderChange: (val) => { + densityDecay = val; + }, + }, + + 'Heat Retention': { + initial: defaults.tempDecay, + min: 0.9, + max: 1.0, + step: 0.0001, + onSliderChange: (val) => { + tempDecay = val; + }, + }, + }), +}); + +function hideHelp() { + const helpElem = document.getElementById('help'); + if (helpElem) { + helpElem.style.opacity = '0'; + } +} +for (const eventName of ['click', 'keydown', 'wheel', 'touchstart']) { + canvas.addEventListener(eventName, hideHelp, { once: true, passive: true }); +} + +export function onCleanup() { + cancelAnimationFrame(animationFrameId); + resizeObserver.disconnect(); + canvas.removeEventListener('pointerdown', onPointerDown); + canvas.removeEventListener('pointermove', onPointerMove); + canvas.removeEventListener('pointerup', onPointerUp); + canvas.removeEventListener('pointercancel', onPointerCancel); + textMask.cleanup(); + root.destroy(); +} + +// #endregion diff --git a/apps/typegpu-docs/src/examples/simulation/fire-text/meta.json b/apps/typegpu-docs/src/examples/simulation/fire-text/meta.json new file mode 100644 index 0000000000..ef57db165d --- /dev/null +++ b/apps/typegpu-docs/src/examples/simulation/fire-text/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Fire Text", + "category": "simulation", + "tags": ["stable fluids", "fire", "sandbox"], + "coolFactor": 9 +} diff --git a/apps/typegpu-docs/src/examples/simulation/fire-text/particles.ts b/apps/typegpu-docs/src/examples/simulation/fire-text/particles.ts new file mode 100644 index 0000000000..b57d832d29 --- /dev/null +++ b/apps/typegpu-docs/src/examples/simulation/fire-text/particles.ts @@ -0,0 +1,195 @@ +import { + tgpu, + d, + std, + type SampledFlag, + type StorageFlag, + type TgpuBuffer, + type TgpuRoot, + type TgpuTexture, + type TgpuUniform, +} from 'typegpu'; +import { randf } from '@typegpu/noise'; +import { Config, defaults } from './config.ts'; +import { smokeLayout } from './fluid.ts'; + +type R32Texture = TgpuTexture & SampledFlag & StorageFlag; + +export const MAX_PARTICLES = defaults.maxParticles; + +export const Particle = d.struct({ + pos: d.vec2f, + vel: d.vec2f, + life: d.f32, + maxLife: d.f32, +}); + +const ParticleArray = d.arrayOf(Particle, MAX_PARTICLES); + +export const particleComputeLayout = tgpu.bindGroupLayout({ + particles: { storage: ParticleArray, access: 'mutable' }, +}); + +export const particleRenderLayout = tgpu.bindGroupLayout({ + particles: { storage: ParticleArray, access: 'readonly' }, + textTex: { storageTexture: d.textureStorage2d('r32float', 'read-only') }, +}); + +export function createParticles(root: TgpuRoot, configUniform: TgpuUniform) { + const particleBuffer = root.createBuffer(ParticleArray).$usage('storage').$name('particles'); + + const particleComputeBg = root.createBindGroup(particleComputeLayout, { + particles: particleBuffer, + }); + + const updateParticles = root.createGuardedComputePipeline((idx: number) => { + 'use gpu'; + const dt = configUniform.$.dt; + let p = Particle(particleComputeLayout.$.particles[idx]); + p.life -= dt; + + if (p.life <= 0.0) { + randf.seed2(d.vec2f(d.f32(idx), configUniform.$.time)); + const randU = randf.sample(); + const randV = randf.sample(); + const testUv = d.vec2f(randU, randV); + const fluidState = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.linearSampler, + testUv, + 0.0, + ); + const temperature = fluidState.w; + const spawnChance = randf.sample(); + + const maskVal = std.textureLoad( + smokeLayout.$.textSourceTex, + d.vec2u(testUv * configUniform.$.textureSize), + ).x; + + if (temperature > 0.8 && spawnChance < 0.4 && maskVal < 0.05) { + p.pos = testUv * configUniform.$.textureSize; + p.vel = d.vec2f((randU * 2.0 - 1.0) * 160.0, -50.0 - randf.sample() * 50.0); + p.maxLife = 0.3 + randf.sample() * 1.0; + p.life = p.maxLife; + } + } else { + const uv = p.pos / configUniform.$.textureSize; + const fluidState = std.textureSampleLevel( + smokeLayout.$.inTex, + smokeLayout.$.linearSampler, + uv, + 0.0, + ); + const fluidVel = fluidState.xy; + p.vel = p.vel * 0.7 + fluidVel * 0.3; + + p.pos += p.vel * dt; + } + + particleComputeLayout.$.particles[idx] = Particle(p); + }); + + const particleVertex = tgpu.vertexFn({ + in: { + vIdx: d.builtin.vertexIndex, + iIdx: d.builtin.instanceIndex, + }, + out: { + pos: d.builtin.position, + life: d.f32, + offset: d.vec2f, + texUv: d.vec2f, + }, + })((input) => { + 'use gpu'; + const p = Particle(particleRenderLayout.$.particles[input.iIdx]); + + if (p.life <= 0.0) { + return { + pos: d.vec4f(-2000.0, -2000.0, 0.0, 1.0), + life: 0.0, + offset: d.vec2f(0.0), + texUv: d.vec2f(0.0), + }; + } + + const size = configUniform.$.particleSize; + let offset = d.vec2f(0.0); + if (input.vIdx === 0) offset = d.vec2f(-1.0, -1.0); + if (input.vIdx === 1) offset = d.vec2f(1.0, -1.0); + if (input.vIdx === 2) offset = d.vec2f(-1.0, 1.0); + if (input.vIdx === 3) offset = d.vec2f(-1.0, 1.0); + if (input.vIdx === 4) offset = d.vec2f(1.0, -1.0); + if (input.vIdx === 5) offset = d.vec2f(1.0, 1.0); + const screenPos = (p.pos / configUniform.$.textureSize) * 2.0 - d.vec2f(1.0); + const finalPos = screenPos + (offset * size) / configUniform.$.textureSize; + return { + pos: d.vec4f(finalPos.x, -finalPos.y, 0.0, 1.0), + life: p.life / p.maxLife, + offset: offset, + texUv: finalPos * 0.5 + d.vec2f(0.5), + }; + }); + + const particleFragment = tgpu.fragmentFn({ + in: { life: d.f32, offset: d.vec2f, texUv: d.vec2f }, + out: { color: d.vec4f }, + })((input) => { + 'use gpu'; + const dist = std.length(input.offset); + const falloff = d.f32(1.0) - std.smoothstep(0.0, 1.0, dist); + const intensity = std.max(0.0, input.life); + + // letters occlude sparks (same edge curve as the text overlay in render.ts) + const uvC = std.clamp(input.texUv, d.vec2f(0.0), d.vec2f(0.9999)); + const mask = std.textureLoad( + particleRenderLayout.$.textTex, + d.vec2u(uvC * configUniform.$.textureSize), + ).x; + const occlusion = d.f32(1.0) - std.smoothstep(0.15, 0.45, mask); + + const a = intensity * falloff * occlusion; + return { color: d.vec4f(1.0 * a, 0.5 * a, 0.1 * a, 1.0) }; + }); + + const particlePipeline = root.createRenderPipeline({ + vertex: particleVertex, + fragment: particleFragment, + targets: { + color: { + format: navigator.gpu.getPreferredCanvasFormat(), + blend: { + color: { operation: 'add', srcFactor: 'one', dstFactor: 'one' }, + alpha: { operation: 'add', srcFactor: 'one', dstFactor: 'one' }, + }, + }, + }, + }); + + return { + particleBuffer, + particleComputeBg, + updateParticles, + particlePipeline, + particleVertex, + particleFragment, + MAX_PARTICLES, + }; +} + +export function createParticleRenderBindGroup( + root: TgpuRoot, + { + particleBuffer, + textSourceGrid, + }: { + particleBuffer: TgpuBuffer & StorageFlag; + textSourceGrid: R32Texture; + }, +) { + return root.createBindGroup(particleRenderLayout, { + particles: particleBuffer, + textTex: textSourceGrid, + }); +} diff --git a/apps/typegpu-docs/src/examples/simulation/fire-text/render.ts b/apps/typegpu-docs/src/examples/simulation/fire-text/render.ts new file mode 100644 index 0000000000..5b1936a776 --- /dev/null +++ b/apps/typegpu-docs/src/examples/simulation/fire-text/render.ts @@ -0,0 +1,170 @@ +import { + tgpu, + common, + d, + std, + type SampledFlag, + type StorageFlag, + type TgpuRoot, + type TgpuSampler, + type TgpuTexture, + type TgpuUniform, +} from 'typegpu'; +import type { Config } from './config.ts'; + +type Rgba16Texture = TgpuTexture & SampledFlag & StorageFlag; + +export const displayLayout = tgpu.bindGroupLayout({ + linearSampler: { sampler: 'filtering' }, + displayTex: { texture: d.texture2d(d.f32) }, +}); + +export function smokeFragment(configUniform: TgpuUniform) { + return tgpu.fragmentFn({ + in: { uv: d.vec2f }, + out: d.vec4f, + })(({ uv }) => { + 'use gpu'; + const texel = std.textureSampleLevel( + displayLayout.$.displayTex, + displayLayout.$.linearSampler, + uv, + 0, + ); + const density = texel.z; + const rawTemperature = texel.w; + + const temperature = std.pow(rawTemperature, configUniform.$.tempPower); + + // plancks law approximation (tanner helland algorithm) + // map normalized temperature to kelvins (1000K to 4000K) + const T = 1000.0 + temperature * 3000.0; + const t = T / 100.0; + + let r = d.f32(0); + if (t <= 66.0) { + r = d.f32(255.0); + } else { + r = d.f32(329.698727446) * std.pow(t - 60.0, -0.1332047592); + } + + let g = d.f32(0); + if (t <= 66.0) { + g = d.f32(99.4708025861) * std.log(t) - d.f32(161.1195681661); + } else { + g = d.f32(288.1221695283) * std.pow(t - 60.0, -0.0755148492); + } + + let b = d.f32(0); + if (t >= 66.0) { + b = d.f32(255.0); + } else if (t <= 19.0) { + b = d.f32(0.0); + } else { + b = d.f32(138.5177312231) * std.log(t - 10.0) - d.f32(305.0447927307); + } + + const color = + d.vec3f(std.clamp(r, 0.0, 255.0), std.clamp(g, 0.0, 255.0), std.clamp(b, 0.0, 255.0)) / 255.0; + const intensity = density * temperature * 2.5; + + const bgColor = d.vec3f(0.1, 0.1, 0.1); + const smokeColor = d.vec3f(0.04, 0.04, 0.04); + const fireColor = color * intensity; + const fluidColor = fireColor + smokeColor; + const finalColor = std.mix(bgColor, fluidColor, std.min(density, 1.0)); + + return d.vec4f(finalColor, 1.0); + }); +} + +export function densityFragment(_configUniform: TgpuUniform) { + return tgpu.fragmentFn({ + in: { uv: d.vec2f }, + out: d.vec4f, + })(({ uv }) => { + 'use gpu'; + const texel = std.textureSampleLevel( + displayLayout.$.displayTex, + displayLayout.$.linearSampler, + uv, + 0, + ); + const density = texel.z; + + const bgColor = d.vec3f(0.1, 0.1, 0.1); + const densityColor = d.vec3f(density); + const finalColor = std.mix(bgColor, densityColor, std.min(density, 1.0)); + + return d.vec4f(finalColor, 1.0); + }); +} + +export function velocityFragment(_configUniform: TgpuUniform) { + return tgpu.fragmentFn({ + in: { uv: d.vec2f }, + out: d.vec4f, + })(({ uv }) => { + 'use gpu'; + const texel = std.textureSampleLevel( + displayLayout.$.displayTex, + displayLayout.$.linearSampler, + uv, + 0, + ); + const vel = texel.xy; + const speed = std.length(vel); + + const bgColor = d.vec3f(0.1, 0.1, 0.1); + + const normVel = vel * 0.02; + const dirColor = d.vec3f( + std.clamp(0.5 + normVel.x, 0.0, 1.0), + std.clamp(0.5 + normVel.y, 0.0, 1.0), + std.clamp(speed * 0.02, 0.0, 1.0), + ); + + const finalColor = std.mix(bgColor, dirColor, std.min(speed * 0.05, 1.0)); + + return d.vec4f(finalColor, 1.0); + }); +} + +export function createRenderPipelines(root: TgpuRoot, configUniform: TgpuUniform) { + return { + firePipeline: root.createRenderPipeline({ + vertex: common.fullScreenTriangle, + fragment: smokeFragment(configUniform), + }), + densityPipeline: root.createRenderPipeline({ + vertex: common.fullScreenTriangle, + fragment: densityFragment(configUniform), + }), + velocityPipeline: root.createRenderPipeline({ + vertex: common.fullScreenTriangle, + fragment: velocityFragment(configUniform), + }), + }; +} + +export function createDisplayBindGroups( + root: TgpuRoot, + { + linearSampler, + smokeGrid, + }: { + linearSampler: TgpuSampler; + smokeGrid: [Rgba16Texture, Rgba16Texture]; + }, +) { + return [ + root.createBindGroup(displayLayout, { + linearSampler, + displayTex: smokeGrid[1], + }), + root.createBindGroup(displayLayout, { + linearSampler, + displayTex: smokeGrid[0], + }), + ]; +} diff --git a/apps/typegpu-docs/src/examples/simulation/fire-text/text.ts b/apps/typegpu-docs/src/examples/simulation/fire-text/text.ts new file mode 100644 index 0000000000..8a1ee48c8c --- /dev/null +++ b/apps/typegpu-docs/src/examples/simulation/fire-text/text.ts @@ -0,0 +1,193 @@ +import { defaults } from './config.ts'; + +type WritableTexture = { write(data: Float32Array): void }; + +export interface CreateTextMaskOptions { + getTextSourceGrid: () => WritableTexture; + getTextFillGrid: () => WritableTexture; + getTextureSize: () => number; + outlineWidth?: number; + initialText?: string; +} + +export function createTextMask(options: CreateTextMaskOptions) { + const { getTextSourceGrid, getTextFillGrid, getTextureSize } = options; + const outlineWidth = options.outlineWidth ?? defaults.textOutlineWidth; + + let currentTextureSize = getTextureSize(); + const maskCanvas = document.createElement('canvas'); + maskCanvas.width = currentTextureSize; + maskCanvas.height = currentTextureSize; + const maybeMaskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + if (!maybeMaskCtx) { + throw new Error('Failed to get 2D context'); + } + const maskCtx = maybeMaskCtx; + + let floatData = new Float32Array(currentTextureSize * currentTextureSize); + let fillData = new Float32Array(currentTextureSize * currentTextureSize); + + let text = options.initialText ?? defaults.text; + let caretVisible = true; + let blinkEnabled = true; + let blinkTimer: ReturnType | undefined; + + function readAlphaInto(target: Float32Array, boost = 1) { + const pixels = maskCtx.getImageData(0, 0, currentTextureSize, currentTextureSize).data; + for (let i = 0; i < target.length; i++) { + target[i] = Math.min(1, (pixels[i * 4 + 3] / 255) * boost); + } + } + + function uploadMask() { + const BASE_FONT_SIZE = Math.round(currentTextureSize * 0.15); + const MARGIN = Math.round(currentTextureSize * 0.047); + + maskCtx.clearRect(0, 0, currentTextureSize, currentTextureSize); + + const lines = text.split('\n'); + + let fontSize = BASE_FONT_SIZE; + maskCtx.font = `${fontSize}px sans-serif`; + const widest = Math.max(1, ...lines.map((l) => maskCtx.measureText(l).width)); + const maxWidth = currentTextureSize - MARGIN * 2; + if (widest > maxWidth) { + fontSize = Math.max(14, Math.floor(fontSize * (maxWidth / widest))); + maskCtx.font = `${fontSize}px sans-serif`; + } + + const lineHeight = fontSize * 1.3; + const blockHeight = lines.length * lineHeight; + const firstBaseline = (currentTextureSize - blockHeight) / 2 + fontSize; + + maskCtx.textBaseline = 'alphabetic'; + maskCtx.lineWidth = outlineWidth; + maskCtx.lineJoin = 'round'; + maskCtx.lineCap = 'round'; + maskCtx.strokeStyle = 'rgba(255, 255, 255, 1)'; + + let caretX = currentTextureSize / 2; + let caretBaseline = firstBaseline; + + const layout = lines.map((line, i) => { + const width = maskCtx.measureText(line).width; + const x = (currentTextureSize - width) / 2; + const y = firstBaseline + i * lineHeight; + if (i === lines.length - 1) { + caretX = x + width; + caretBaseline = y; + } + return { line, x, y }; + }); + + maskCtx.globalCompositeOperation = 'source-over'; + for (const { line, x, y } of layout) { + if (line.length > 0) maskCtx.strokeText(line, x, y); + } + if (caretVisible) { + maskCtx.fillStyle = 'rgba(255, 255, 255, 1)'; + maskCtx.fillRect(caretX + 4, caretBaseline - fontSize * 0.8, 3, fontSize * 0.9); + } + + maskCtx.globalCompositeOperation = 'destination-out'; + maskCtx.fillStyle = 'rgba(255, 255, 255, 1)'; + for (const { line, x, y } of layout) { + if (line.length > 0) maskCtx.fillText(line, x, y); + } + + readAlphaInto(floatData, 1.0); + getTextSourceGrid().write(floatData); + + maskCtx.clearRect(0, 0, currentTextureSize, currentTextureSize); + maskCtx.globalCompositeOperation = 'source-over'; + maskCtx.fillStyle = 'rgba(255, 255, 255, 1)'; + for (const { line, x, y } of layout) { + if (line.length > 0) maskCtx.fillText(line, x, y); + } + readAlphaInto(fillData); + getTextFillGrid().write(fillData); + } + + function restartBlink() { + clearInterval(blinkTimer); + if (!blinkEnabled) { + caretVisible = false; + return; + } + blinkTimer = setInterval(() => { + caretVisible = !caretVisible; + uploadMask(); + }, 800); + } + + function setCursorBlink(enabled: boolean) { + blinkEnabled = enabled; + caretVisible = enabled; + restartBlink(); + uploadMask(); + } + + function setText(newText: string) { + text = newText; + caretVisible = blinkEnabled; + restartBlink(); + uploadMask(); + } + + function setTextureSize(newSize: number) { + currentTextureSize = newSize; + maskCanvas.width = newSize; + maskCanvas.height = newSize; + floatData = new Float32Array(newSize * newSize); + fillData = new Float32Array(newSize * newSize); + uploadMask(); + } + + function onKeyDown(e: KeyboardEvent) { + const target = e.target as HTMLElement | null; + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLButtonElement + ) + return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + + if (e.key === 'Backspace') { + text = text.slice(0, -1); + } else if (e.key === 'Enter') { + text += '\n'; + } else if (e.key === 'Escape') { + text = ''; + } else if (e.key.length === 1) { + text += e.key; + } else { + return; + } + + e.preventDefault(); + caretVisible = blinkEnabled; + restartBlink(); + uploadMask(); + } + + function start() { + restartBlink(); + uploadMask(); + window.addEventListener('keydown', onKeyDown); + } + + function cleanup() { + clearInterval(blinkTimer); + window.removeEventListener('keydown', onKeyDown); + } + + return { + setText, + setCursorBlink, + setTextureSize, + uploadMask, + start, + cleanup, + }; +} diff --git a/apps/typegpu-docs/src/examples/simulation/fire-text/thumbnail.png b/apps/typegpu-docs/src/examples/simulation/fire-text/thumbnail.png new file mode 100644 index 0000000000..ffeff58efb Binary files /dev/null and b/apps/typegpu-docs/src/examples/simulation/fire-text/thumbnail.png differ diff --git a/apps/typegpu-docs/src/utils/examples/exampleControlAtom.ts b/apps/typegpu-docs/src/utils/examples/exampleControlAtom.ts index eac767df59..bbe6e2b50b 100644 --- a/apps/typegpu-docs/src/utils/examples/exampleControlAtom.ts +++ b/apps/typegpu-docs/src/utils/examples/exampleControlAtom.ts @@ -49,7 +49,12 @@ export type TextAreaControlParam = { label: string; }; -export type ExampleControlParam = +export type SectionControlParam = { + isSection: true; + label: string; +}; + +type LabeledControlParam = | SelectControlParam | ToggleControlParam | SliderControlParam @@ -60,4 +65,8 @@ export type ExampleControlParam = | VectorSliderControlParam | ColorPickerControlParam; +export type ExampleControlParam = + | SectionControlParam + | (LabeledControlParam & { isSection?: false }); + export const exampleControlsAtom = atom([]); diff --git a/apps/typegpu-docs/src/utils/examples/exampleRunner.ts b/apps/typegpu-docs/src/utils/examples/exampleRunner.ts index 0188153745..86fc304aa1 100644 --- a/apps/typegpu-docs/src/utils/examples/exampleRunner.ts +++ b/apps/typegpu-docs/src/utils/examples/exampleRunner.ts @@ -1,34 +1,14 @@ -import type { d } from 'typegpu'; +import { + flattenControls, + initializeControlParam, + isFlatSection, +} from '../../examples/common/flattenControls.ts'; import type { ExampleControlParam } from './exampleControlAtom.ts'; import type { ExampleState } from './exampleState.ts'; -type Labelless = T extends unknown ? Omit : never; - -function initializeParam(param: ExampleControlParam) { - if ('onSelectChange' in param) { - return param.onSelectChange(param.initial); - } - if ('onToggleChange' in param) { - return param.onToggleChange(param.initial); - } - if ('onSliderChange' in param) { - return param.onSliderChange(param.initial); - } - if ('onVectorSliderChange' in param) { - return (param.onVectorSliderChange as (v: d.v2f | d.v3f | d.v4f) => void)(param.initial); - } - if ('onColorChange' in param) { - return param.onColorChange(param.initial); - } - if ('onTextChange' in param) { - return param.onTextChange(param.initial); - } -} - export async function executeExample(tsImport: () => unknown): Promise { const cleanupCallbacks: (() => unknown)[] = []; let disposed = false; - const controlParams: ExampleControlParam[] = []; const dispose = () => { if (disposed) { @@ -40,33 +20,20 @@ export async function executeExample(tsImport: () => unknown): Promise | false>) { - for (const [label, value] of Object.entries(options)) { - if (!value) { - continue; - } - - const param = { - ...value, - label, - }; - - controlParams.push(param); - - // Eager run to initialize the values. - initializeParam(param); - } - } - const entryExampleFile = await tsImport(); const { controls, onCleanup } = entryExampleFile as { - controls?: Record | false> | undefined; + controls?: Record | undefined; onCleanup?: () => void; }; - if (controls) { - addParameters(controls); + const controlParams = controls ? (flattenControls(controls) as ExampleControlParam[]) : []; + + for (const param of controlParams) { + if (!isFlatSection(param)) { + initializeControlParam(param); + } } + if (onCleanup) { cleanupCallbacks.push(onCleanup); }