From a349f630c60cb548ccecf5c5087dd842f459dc21 Mon Sep 17 00:00:00 2001 From: Andreas Dahm Date: Thu, 16 Apr 2026 10:04:48 +0200 Subject: [PATCH] Updated the webgpu stuff to have webgl as fallback --- .../algorithms/cloth/cloth-glsl.shader.ts | 48 +++ .../pages/algorithms/cloth/cloth.component.ts | 332 +++--------------- src/app/pages/algorithms/cloth/cloth.model.ts | 22 +- .../cloth/strategies/cloth-cpu-physics.ts | 149 ++++++++ .../cloth/strategies/cloth-cpu.strategy.ts | 216 ++++++++++++ .../cloth/strategies/cloth-gpu.strategy.ts | 275 +++++++++++++++ .../strategies/cloth-simulation.strategy.ts | 17 + .../pendulum/pendulum-glsl.shader.ts | 134 +++++++ .../algorithms/pendulum/pendulum.component.ts | 109 ++---- .../strategies/pendulum-cpu-physics.ts | 50 +++ .../strategies/pendulum-cpu.strategy.ts | 134 +++++++ .../strategies/pendulum-gpu.strategy.ts | 112 ++++++ .../pendulum-simulation.strategy.ts | 21 ++ src/app/service/gpu-capability.service.ts | 55 +++ .../render-canvas/babylon-canvas.component.ts | 86 +++-- src/assets/i18n/de.json | 5 +- src/assets/i18n/en.json | 5 +- 17 files changed, 1352 insertions(+), 418 deletions(-) create mode 100644 src/app/pages/algorithms/cloth/cloth-glsl.shader.ts create mode 100644 src/app/pages/algorithms/cloth/strategies/cloth-cpu-physics.ts create mode 100644 src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts create mode 100644 src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts create mode 100644 src/app/pages/algorithms/cloth/strategies/cloth-simulation.strategy.ts create mode 100644 src/app/pages/algorithms/pendulum/pendulum-glsl.shader.ts create mode 100644 src/app/pages/algorithms/pendulum/strategies/pendulum-cpu-physics.ts create mode 100644 src/app/pages/algorithms/pendulum/strategies/pendulum-cpu.strategy.ts create mode 100644 src/app/pages/algorithms/pendulum/strategies/pendulum-gpu.strategy.ts create mode 100644 src/app/pages/algorithms/pendulum/strategies/pendulum-simulation.strategy.ts create mode 100644 src/app/service/gpu-capability.service.ts diff --git a/src/app/pages/algorithms/cloth/cloth-glsl.shader.ts b/src/app/pages/algorithms/cloth/cloth-glsl.shader.ts new file mode 100644 index 0000000..31e7574 --- /dev/null +++ b/src/app/pages/algorithms/cloth/cloth-glsl.shader.ts @@ -0,0 +1,48 @@ +/** + * GLSL shaders for cloth rendering on WebGL. + * Replicates the visual output of the WGSL cloth shaders: + * checkerboard pattern with Lambertian lighting. + */ + +export const CLOTH_VERTEX_SHADER_GLSL = ` + precision highp float; + + attribute vec3 position; + attribute vec2 uv; + + uniform mat4 viewProjection; + + varying vec2 vUV; + varying vec3 vWorldPos; + + void main() { + vUV = uv; + vWorldPos = position; + gl_Position = viewProjection * vec4(position, 1.0); + } +`; + +export const CLOTH_FRAGMENT_SHADER_GLSL = ` + #extension GL_OES_standard_derivatives : enable + precision highp float; + + varying vec2 vUV; + varying vec3 vWorldPos; + + void main() { + vec3 dx = dFdx(vWorldPos); + vec3 dy = dFdy(vWorldPos); + vec3 normal = normalize(cross(dx, dy)); + + vec3 lightDir = normalize(vec3(1.0, 1.0, 0.5)); + float diffuse = max(0.0, abs(dot(normal, lightDir))); + float ambient = 0.3; + float lightIntensity = ambient + (diffuse * 0.7); + + float grid = mod(floor(vUV.x * 20.0) + floor(vUV.y * 20.0), 2.0); + vec3 baseColor = mix(vec3(0.8, 0.4, 0.15), vec3(0.9, 0.5, 0.2), grid); + vec3 finalColor = baseColor * lightIntensity; + + gl_FragColor = vec4(finalColor, 1.0); + } +`; diff --git a/src/app/pages/algorithms/cloth/cloth.component.ts b/src/app/pages/algorithms/cloth/cloth.component.ts index b6b5457..5d83cbb 100644 --- a/src/app/pages/algorithms/cloth/cloth.component.ts +++ b/src/app/pages/algorithms/cloth/cloth.component.ts @@ -1,27 +1,17 @@ -/** - * File: cloth.component.ts - * Description: Component for cloth simulation using WebGPU compute shaders. - */ - -import { Component } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatCard, MatCardContent, MatCardHeader, MatCardTitle } from '@angular/material/card'; -import { MatSliderModule } from '@angular/material/slider'; -import { TranslatePipe } from '@ngx-translate/core'; -import { BabylonCanvas, RenderConfig, SceneEventData } from '../../../shared/components/render-canvas/babylon-canvas.component'; -import {ComputeShader, StorageBuffer, MeshBuilder, ShaderMaterial, ShaderLanguage, ArcRotateCamera, GroundMesh, WebGPUEngine, Scene} from '@babylonjs/core'; -import { - CLOTH_FRAGMENT_SHADER_WGSL, - CLOTH_INTEGRATE_COMPUTE_WGSL, - CLOTH_SOLVE_COMPUTE_WGSL, - CLOTH_VELOCITY_COMPUTE_WGSL, - CLOTH_VERTEX_SHADER_WGSL -} from './cloth.shader'; +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; +import {MatSliderModule} from '@angular/material/slider'; +import {TranslatePipe} from '@ngx-translate/core'; +import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component'; import {MatButton} from '@angular/material/button'; -import {ClothBuffers, ClothConfig, ClothData, ClothPipelines} from './cloth.model'; +import {ClothConfig} from './cloth.model'; import {Information} from '../information/information'; import {AlgorithmInformation} from '../information/information.models'; import {UrlConstants} from '../../../constants/UrlConstants'; +import {ClothSimulationStrategy} from './strategies/cloth-simulation.strategy'; +import {ClothGpuStrategy} from './strategies/cloth-gpu.strategy'; +import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy'; @Component({ selector: 'app-cloth', @@ -43,17 +33,16 @@ import {UrlConstants} from '../../../constants/UrlConstants'; export class ClothComponent { private currentSceneData: SceneEventData | null = null; private simulationTime: number = 0; - private clothMesh: GroundMesh | null = null; + private strategy: ClothSimulationStrategy | null = null; + public isWindActive: boolean = false; public isOutlineActive: boolean = false; public stiffness: number = 80; - // Elongation along the vertical (Y) axis, 0.5 = compressed, 2.0 = stretched public elongation: number = 1.0; public renderConfig: RenderConfig = { mode: '3D', - initialViewSize: 20, - shaderLanguage: ShaderLanguage.WGSL + initialViewSize: 20 }; algoInformation: AlgorithmInformation = { @@ -89,10 +78,6 @@ export class ClothComponent { disclaimerListEntry: ['CLOTH.EXPLANATION.DISCLAIMER_1', 'CLOTH.EXPLANATION.DISCLAIMER_2', 'CLOTH.EXPLANATION.DISCLAIMER_3', 'CLOTH.EXPLANATION.DISCLAIMER_4'] }; - /** - * Called when the Babylon scene is ready. - * @param event The scene event data. - */ public onSceneReady(event: SceneEventData): void { this.currentSceneData = event; this.createSimulation(); @@ -104,10 +89,11 @@ export class ClothComponent { public toggleMesh(): void { this.isOutlineActive = !this.isOutlineActive; - if (!this.clothMesh?.material) { + const mesh = this.strategy?.getMesh(); + if (!mesh?.material) { return; } - this.clothMesh.material.wireframe = this.isOutlineActive; + mesh.material.wireframe = this.isOutlineActive; } public restartSimulation(): void { @@ -115,36 +101,43 @@ export class ClothComponent { this.createSimulation(); } - /** - * Initializes and starts the cloth simulation. - */ private createSimulation(): void { - if (!this.currentSceneData) return; + if (!this.currentSceneData) { + return; + } - const { engine, scene } = this.currentSceneData; - - // 1. Define physics parameters + const {engine, scene, gpuTier} = this.currentSceneData; const config = this.getClothConfig(); - // 2. Generate initial CPU data (positions, constraints) - const clothData = this.generateClothData(config); + if (this.strategy) { + this.strategy.dispose(); + } - // 3. Upload to GPU - const buffers = this.createStorageBuffers(engine, clothData); + this.strategy = gpuTier === 'webgpu' + ? new ClothGpuStrategy() + : new ClothCpuStrategy(); - // 4. Create Compute Shaders - const pipelines = this.setupComputePipelines(engine, buffers); - - // 5. Setup Rendering (Mesh, Material, Camera) - this.setupRenderMesh(scene, config, buffers.positions); - - // 6. Start the physics loop - this.startRenderLoop(engine, scene, config, buffers, pipelines); + this.strategy.init(scene, engine, config); + this.startParamUpdateLoop(scene, engine); + } + + private startParamUpdateLoop(scene: any, engine: any): void { + scene.onAfterRenderObservable.clear(); + scene.onAfterRenderObservable.add(() => { + this.simulationTime += engine.getDeltaTime() / 1000.0; + + if (this.strategy) { + this.strategy.updateParams({ + stiffness: this.stiffness, + elongation: this.elongation, + isWindActive: this.isWindActive, + simulationTime: this.simulationTime, + deltaTime: engine.getDeltaTime() / 1000.0 + }); + } + }); } - // ======================================================================== - // 1. CONFIGURATION - // ======================================================================== private getClothConfig(): ClothConfig { const gridWidth = 100; const gridHeight = 100; @@ -162,239 +155,4 @@ export class ClothComponent { particleInvMass: 1.0 / particleMass }; } - - // ======================================================================== - // 2. DATA GENERATION (CPU) - // ======================================================================== - private generateClothData(config: ClothConfig): ClothData { - const positionsData = new Float32Array(config.numVertices * 4); - const prevPositionsData = new Float32Array(config.numVertices * 4); - const velocitiesData = new Float32Array(config.numVertices * 4); - - const constraintsP0: number[] = []; - const constraintsP1: number[] = []; - const constraintsP2: number[] = []; - const constraintsP3: number[] = []; - - // Type 1.0 = horizontal/diagonal (no elongation), Type 2.0 = vertical (elongation applies) - const addHorizontalConstraint = (arr: number[], a: number, b: number): void => { - arr.push(a, b, config.spacing, 1.0); - }; - const addVerticalConstraint = (arr: number[], a: number, b: number): void => { - arr.push(a, b, config.spacing, 2.0); - }; - - // Fill positions (Pin top row) - for (let y = 0; y < config.gridHeight; y++) { - for (let x = 0; x < config.gridWidth; x++) { - const idx = (y * config.gridWidth + x) * 4; - positionsData[idx + 0] = (x - config.gridWidth / 2) * config.spacing; - positionsData[idx + 1] = 5.0 - (y * config.spacing); - positionsData[idx + 2] = 0.0; - positionsData[idx + 3] = (y === 0) ? 0.0 : config.particleInvMass; - - prevPositionsData[idx + 0] = positionsData[idx + 0]; - prevPositionsData[idx + 1] = positionsData[idx + 1]; - prevPositionsData[idx + 2] = positionsData[idx + 2]; - prevPositionsData[idx + 3] = positionsData[idx + 3]; - } - } - - // Graph Coloring (4 Phases) - for (let y = 0; y < config.gridHeight; y++) { - for (let x = 0; x < config.gridWidth - 1; x += 2) addHorizontalConstraint(constraintsP0, y * config.gridWidth + x, y * config.gridWidth + x + 1); - for (let x = 1; x < config.gridWidth - 1; x += 2) addHorizontalConstraint(constraintsP1, y * config.gridWidth + x, y * config.gridWidth + x + 1); - } - for (let y = 0; y < config.gridHeight - 1; y += 2) { - for (let x = 0; x < config.gridWidth; x++) addVerticalConstraint(constraintsP2, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); - } - for (let y = 1; y < config.gridHeight - 1; y += 2) { - for (let x = 0; x < config.gridWidth; x++) addVerticalConstraint(constraintsP3, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); - } - - const constraintsP4: number[] = []; - const constraintsP5: number[] = []; - const constraintsP6: number[] = []; - const constraintsP7: number[] = []; - - const diagSpacing = config.spacing * Math.SQRT2; - const addDiagConstraint = (arr: number[], a: number, b: number): void => { - arr.push(a, b, diagSpacing, 1.0); - }; - - for (let y = 0; y < config.gridHeight - 1; y++) { - const arr = (y % 2 === 0) ? constraintsP4 : constraintsP5; - for (let x = 0; x < config.gridWidth - 1; x++) { - addDiagConstraint(arr, y * config.gridWidth + x, (y + 1) * config.gridWidth + (x + 1)); - } - } - - for (let y = 0; y < config.gridHeight - 1; y++) { - const arr = (y % 2 === 0) ? constraintsP6 : constraintsP7; - for (let x = 0; x < config.gridWidth - 1; x++) { - addDiagConstraint(arr, y * config.gridWidth + (x + 1), (y + 1) * config.gridWidth + x); - } - } - - return { - positions: positionsData, - prevPositions: prevPositionsData, - velocities: velocitiesData, - constraints: [ - constraintsP0, constraintsP1, constraintsP2, constraintsP3, - constraintsP4, constraintsP5, constraintsP6, constraintsP7 - ], - params: new Float32Array(9) - }; - } - - // ======================================================================== - // 3. BUFFER CREATION (GPU) - // ======================================================================== - private createStorageBuffers(engine: WebGPUEngine, data: ClothData): ClothBuffers { - const createBuffer = (arrayData: Float32Array | number[]): StorageBuffer => { - const buffer = new StorageBuffer(engine, arrayData.length * 4); - buffer.update(arrayData instanceof Float32Array ? arrayData : new Float32Array(arrayData)); - return buffer; - }; - - return { - positions: createBuffer(data.positions), - prevPositions: createBuffer(data.prevPositions), - velocities: createBuffer(data.velocities), - params: createBuffer(data.params), - constraints: data.constraints.map(cData => createBuffer(cData)) - }; - } - - // ======================================================================== - // 4. COMPUTE SHADERS - // ======================================================================== - private setupComputePipelines(engine: WebGPUEngine, buffers: ClothBuffers): ClothPipelines { - - // Helper for integrating & velocity - const createBasicShader = (name: string, source: string) => { - const cs = new ComputeShader(name, engine, { computeSource: source }, { - bindingsMapping: { - "p": { group: 0, binding: 0 }, - "positions": { group: 0, binding: 1 }, - "prev_positions": { group: 0, binding: 2 }, - "velocities": { group: 0, binding: 3 } - } - }); - cs.setStorageBuffer("p", buffers.params); - cs.setStorageBuffer("positions", buffers.positions); - cs.setStorageBuffer("prev_positions", buffers.prevPositions); - cs.setStorageBuffer("velocities", buffers.velocities); - return cs; - }; - - // Helper for solvers - const createSolverShader = (name: string, constraintBuffer: StorageBuffer) => { - const cs = new ComputeShader(name, engine, { computeSource: CLOTH_SOLVE_COMPUTE_WGSL }, { - bindingsMapping: { - "p": { group: 0, binding: 0 }, - "positions": { group: 0, binding: 1 }, - "constraints": { group: 0, binding: 2 } - } - }); - cs.setStorageBuffer("p", buffers.params); - cs.setStorageBuffer("positions", buffers.positions); - cs.setStorageBuffer("constraints", constraintBuffer); - return cs; - }; - - return { - integrate: createBasicShader("integrate", CLOTH_INTEGRATE_COMPUTE_WGSL), - solvers: buffers.constraints.map((cBuffer, i) => createSolverShader(`solve${i}`, cBuffer)), - velocity: createBasicShader("velocity", CLOTH_VELOCITY_COMPUTE_WGSL) - }; - } - - // ======================================================================== - // 5. RENDERING SETUP - // ======================================================================== - private setupRenderMesh(scene: Scene, config: ClothConfig, positionsBuffer: StorageBuffer): void { - if (this.clothMesh) { - scene.removeMesh(this.clothMesh); - } - - this.clothMesh = MeshBuilder.CreateGround("cloth", { width: 10, height: 10, subdivisions: config.gridWidth - 1 }, scene); - - const clothMaterial = new ShaderMaterial("clothMat", scene, { - vertexSource: CLOTH_VERTEX_SHADER_WGSL, - fragmentSource: CLOTH_FRAGMENT_SHADER_WGSL - }, { - attributes: ["position", "uv"], - uniforms: ["viewProjection"], - storageBuffers: ["positions"], - shaderLanguage: ShaderLanguage.WGSL - }); - - clothMaterial.backFaceCulling = false; - clothMaterial.setStorageBuffer("positions", positionsBuffer); - this.clothMesh.material = clothMaterial; - - const camera = scene.activeCamera as ArcRotateCamera; - if (camera) { - camera.alpha = Math.PI / 4; - camera.beta = Math.PI / 2.5; - camera.radius = 15; - } - } - - // ======================================================================== - // 6. RENDER LOOP - // ======================================================================== - private startRenderLoop(engine: WebGPUEngine, scene: Scene, config: ClothConfig, buffers: ClothBuffers, pipelines: ClothPipelines): void { - const paramsData = new Float32Array(9); - - // Pre-calculate constraint dispatch sizes for the 4 phases - const constraintsLength = buffers.constraints.map(b => (b as any)._buffer.capacity / 4 / 4); // Elements / vec4 length - const dispatchXConstraints = constraintsLength.map(len => Math.ceil(len / 64)); - const dispatchXVertices = Math.ceil(config.numVertices / 64); - const substeps = 15; - - scene.onBeforeRenderObservable.clear(); - scene.onBeforeRenderObservable.add(() => { - this.simulationTime += engine.getDeltaTime() / 1000.0; - - // Update Physics Parameters - const windX = this.isWindActive ? 5.0 : 0.0; - const windY = 0.0; - const windZ = this.isWindActive ? 15.0 : 0.0; - - // Logarithmic compliance: stiffness=1 → very soft fabric, stiffness=100 → rigid metal sheet. - // alpha = compliance / dt² must be >> wSum (≈800) to be soft, << wSum to be rigid. - const softCompliance = 10.0; - const rigidCompliance = 0.00001; - const t = (this.stiffness - 1) / 99.0; - const compliance = softCompliance * Math.pow(rigidCompliance / softCompliance, t); - - paramsData[0] = 0.016; // dt - paramsData[1] = -9.81; // gravity - paramsData[2] = compliance; - paramsData[3] = config.numVertices; - paramsData[4] = windX; - paramsData[5] = windY; - paramsData[6] = windZ; - paramsData[7] = this.simulationTime; - paramsData[8] = this.elongation; - - buffers.params.update(paramsData); - - // 1. Predict positions - pipelines.integrate.dispatch(dispatchXVertices, 1, 1); - - // 2. XPBD Solver (Substeps) - Graph Coloring Phase - for (let i = 0; i < substeps; i++) { - for (let phase = 0; phase < pipelines.solvers.length; phase++) { - pipelines.solvers[phase].dispatch(dispatchXConstraints[phase], 1, 1); - } - } - - // 3. Update velocities - pipelines.velocity.dispatch(dispatchXVertices, 1, 1); - }); - } } diff --git a/src/app/pages/algorithms/cloth/cloth.model.ts b/src/app/pages/algorithms/cloth/cloth.model.ts index 494295e..8f26122 100644 --- a/src/app/pages/algorithms/cloth/cloth.model.ts +++ b/src/app/pages/algorithms/cloth/cloth.model.ts @@ -1,6 +1,3 @@ -// --- SIMULATION CONFIGURATION --- -import {ComputeShader, StorageBuffer} from '@babylonjs/core'; - export interface ClothConfig { gridWidth: number; gridHeight: number; @@ -10,27 +7,10 @@ export interface ClothConfig { particleInvMass: number; } -// --- RAW CPU DATA --- export interface ClothData { positions: Float32Array; prevPositions: Float32Array; velocities: Float32Array; - constraints: number[][]; // Array containing the 4 phases + constraints: number[][]; params: Float32Array; } - -// --- WEBGPU BUFFERS --- -export interface ClothBuffers { - positions: StorageBuffer; - prevPositions: StorageBuffer; - velocities: StorageBuffer; - params: StorageBuffer; - constraints: StorageBuffer[]; // 4 phase buffers -} - -// --- COMPUTE PIPELINES --- -export interface ClothPipelines { - integrate: ComputeShader; - solvers: ComputeShader[]; // 4 solve shaders - velocity: ComputeShader; -} diff --git a/src/app/pages/algorithms/cloth/strategies/cloth-cpu-physics.ts b/src/app/pages/algorithms/cloth/strategies/cloth-cpu-physics.ts new file mode 100644 index 0000000..c4e0d86 --- /dev/null +++ b/src/app/pages/algorithms/cloth/strategies/cloth-cpu-physics.ts @@ -0,0 +1,149 @@ +/** + * CPU-side cloth physics mirroring the WGSL compute shaders. + * All data uses the same Float32Array vec4 layout: [x, y, z, invMass] per vertex. + */ + +export interface ClothPhysicsParams { + dt: number; + gravityY: number; + compliance: number; + numVertices: number; + windX: number; + windY: number; + windZ: number; + time: number; + elongation: number; +} + +/** + * Mirrors CLOTH_INTEGRATE_COMPUTE_WGSL: + * Applies gravity and wind forces, predicts new positions. + */ +export function integratePositions( + positions: Float32Array, + prevPositions: Float32Array, + velocities: Float32Array, + params: ClothPhysicsParams +): void { + for (let idx = 0; idx < params.numVertices; idx++) { + const base = idx * 4; + const invMass = positions[base + 3]; + + if (invMass <= 0.0) { + continue; + } + + velocities[base + 1] += params.gravityY * params.dt; + + const posX = positions[base + 0]; + const posY = positions[base + 1]; + + const flutter = Math.sin(posX * 2.0 + params.time * 5.0) * Math.cos(posY * 2.0 + params.time * 3.0); + + const windForceX = params.windX + (flutter * params.windX * 0.8); + const windForceY = params.windY + (flutter * 2.0); + const windForceZ = params.windZ + (flutter * params.windZ * 0.8); + + velocities[base + 0] += windForceX * params.dt; + velocities[base + 1] += windForceY * params.dt; + velocities[base + 2] += windForceZ * params.dt; + + prevPositions[base + 0] = positions[base + 0]; + prevPositions[base + 1] = positions[base + 1]; + prevPositions[base + 2] = positions[base + 2]; + prevPositions[base + 3] = positions[base + 3]; + + positions[base + 0] += velocities[base + 0] * params.dt; + positions[base + 1] += velocities[base + 1] * params.dt; + positions[base + 2] += velocities[base + 2] * params.dt; + } +} + +/** + * Mirrors CLOTH_SOLVE_COMPUTE_WGSL: + * XPBD constraint solving for one phase of constraints. + * Each constraint is stored as [idA, idB, restLength, type] (4 floats). + */ +export function solveConstraints( + positions: Float32Array, + constraints: Float32Array, + params: ClothPhysicsParams +): void { + const numConstraints = constraints.length / 4; + + for (let idx = 0; idx < numConstraints; idx++) { + const cBase = idx * 4; + const constraintType = constraints[cBase + 3]; + + if (constraintType < 0.5) { + continue; + } + + const idA = constraints[cBase + 0]; + const idB = constraints[cBase + 1]; + const restLength = constraints[cBase + 2] * params.elongation; + + const baseA = idA * 4; + const baseB = idB * 4; + + const wA = positions[baseA + 3]; + const wB = positions[baseB + 3]; + const wSum = wA + wB; + + if (wSum <= 0.0) { + continue; + } + + const dirX = positions[baseA + 0] - positions[baseB + 0]; + const dirY = positions[baseA + 1] - positions[baseB + 1]; + const dirZ = positions[baseA + 2] - positions[baseB + 2]; + const dist = Math.sqrt(dirX * dirX + dirY * dirY + dirZ * dirZ); + + if (dist < 0.0001) { + continue; + } + + const nX = dirX / dist; + const nY = dirY / dist; + const nZ = dirZ / dist; + const c = dist - restLength; + + const alpha = params.compliance / (params.dt * params.dt); + const lambda = -c / (wSum + alpha); + + if (wA > 0.0) { + positions[baseA + 0] += nX * (lambda * wA); + positions[baseA + 1] += nY * (lambda * wA); + positions[baseA + 2] += nZ * (lambda * wA); + } + if (wB > 0.0) { + positions[baseB + 0] += nX * (-lambda * wB); + positions[baseB + 1] += nY * (-lambda * wB); + positions[baseB + 2] += nZ * (-lambda * wB); + } + } +} + +/** + * Mirrors CLOTH_VELOCITY_COMPUTE_WGSL: + * Derives velocity from position changes: v = (pos - prevPos) / dt + */ +export function updateVelocities( + positions: Float32Array, + prevPositions: Float32Array, + velocities: Float32Array, + params: ClothPhysicsParams +): void { + for (let idx = 0; idx < params.numVertices; idx++) { + const base = idx * 4; + const invMass = positions[base + 3]; + + if (invMass <= 0.0) { + continue; + } + + velocities[base + 0] = (positions[base + 0] - prevPositions[base + 0]) / params.dt; + velocities[base + 1] = (positions[base + 1] - prevPositions[base + 1]) / params.dt; + velocities[base + 2] = (positions[base + 2] - prevPositions[base + 2]) / params.dt; + } +} diff --git a/src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts b/src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts new file mode 100644 index 0000000..29d1b53 --- /dev/null +++ b/src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts @@ -0,0 +1,216 @@ +import {ArcRotateCamera, Engine, GroundMesh, MeshBuilder, Scene, ShaderMaterial, VertexBuffer, WebGPUEngine} from '@babylonjs/core'; +import {ClothConfig, ClothData} from '../cloth.model'; +import {CLOTH_FRAGMENT_SHADER_GLSL, CLOTH_VERTEX_SHADER_GLSL} from '../cloth-glsl.shader'; +import {ClothPhysicsParams, integratePositions, solveConstraints, updateVelocities} from './cloth-cpu-physics'; +import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy'; + +export class ClothCpuStrategy implements ClothSimulationStrategy { + private clothMesh: GroundMesh | null = null; + private scene: Scene | null = null; + private config: ClothConfig | null = null; + + private positions!: Float32Array; + private prevPositions!: Float32Array; + private velocities!: Float32Array; + private constraintPhases!: Float32Array[]; + + private physicsParams: ClothPhysicsParams = { + dt: 0.016, + gravityY: -9.81, + compliance: 0.00001, + numVertices: 0, + windX: 0, + windY: 0, + windZ: 0, + time: 0, + elongation: 1.0 + }; + + init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void { + this.scene = scene; + this.config = config; + this.physicsParams.numVertices = config.numVertices; + + const clothData = this.generateClothData(config); + this.positions = clothData.positions; + this.prevPositions = clothData.prevPositions; + this.velocities = clothData.velocities; + this.constraintPhases = clothData.constraints.map(c => new Float32Array(c)); + + this.setupRenderMesh(scene, config); + this.startRenderLoop(scene, config); + } + + updateParams(params: ClothSimulationParams): void { + const windX = params.isWindActive ? 5.0 : 0.0; + const windZ = params.isWindActive ? 15.0 : 0.0; + + const softCompliance = 10.0; + const rigidCompliance = 0.00001; + const t = (params.stiffness - 1) / 99.0; + + this.physicsParams.compliance = softCompliance * Math.pow(rigidCompliance / softCompliance, t); + this.physicsParams.windX = windX; + this.physicsParams.windY = 0.0; + this.physicsParams.windZ = windZ; + this.physicsParams.time = params.simulationTime; + this.physicsParams.elongation = params.elongation; + } + + getMesh(): GroundMesh | null { + return this.clothMesh; + } + + dispose(): void { + if (this.scene && this.clothMesh) { + this.scene.removeMesh(this.clothMesh); + } + this.clothMesh = null; + } + + private generateClothData(config: ClothConfig): ClothData { + const positionsData = new Float32Array(config.numVertices * 4); + const prevPositionsData = new Float32Array(config.numVertices * 4); + const velocitiesData = new Float32Array(config.numVertices * 4); + + const constraintsP0: number[] = []; + const constraintsP1: number[] = []; + const constraintsP2: number[] = []; + const constraintsP3: number[] = []; + + const addHorizontalConstraint = (arr: number[], a: number, b: number): void => { + arr.push(a, b, config.spacing, 1.0); + }; + const addVerticalConstraint = (arr: number[], a: number, b: number): void => { + arr.push(a, b, config.spacing, 2.0); + }; + + for (let y = 0; y < config.gridHeight; y++) { + for (let x = 0; x < config.gridWidth; x++) { + const idx = (y * config.gridWidth + x) * 4; + positionsData[idx + 0] = (x - config.gridWidth / 2) * config.spacing; + positionsData[idx + 1] = 5.0 - (y * config.spacing); + positionsData[idx + 2] = 0.0; + positionsData[idx + 3] = (y === 0) ? 0.0 : config.particleInvMass; + + prevPositionsData[idx + 0] = positionsData[idx + 0]; + prevPositionsData[idx + 1] = positionsData[idx + 1]; + prevPositionsData[idx + 2] = positionsData[idx + 2]; + prevPositionsData[idx + 3] = positionsData[idx + 3]; + } + } + + for (let y = 0; y < config.gridHeight; y++) { + for (let x = 0; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP0, y * config.gridWidth + x, y * config.gridWidth + x + 1); } + for (let x = 1; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP1, y * config.gridWidth + x, y * config.gridWidth + x + 1); } + } + for (let y = 0; y < config.gridHeight - 1; y += 2) { + for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP2, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); } + } + for (let y = 1; y < config.gridHeight - 1; y += 2) { + for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP3, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); } + } + + const constraintsP4: number[] = []; + const constraintsP5: number[] = []; + const constraintsP6: number[] = []; + const constraintsP7: number[] = []; + const diagSpacing = config.spacing * Math.SQRT2; + const addDiagConstraint = (arr: number[], a: number, b: number): void => { + arr.push(a, b, diagSpacing, 1.0); + }; + + for (let y = 0; y < config.gridHeight - 1; y++) { + const arr = (y % 2 === 0) ? constraintsP4 : constraintsP5; + for (let x = 0; x < config.gridWidth - 1; x++) { + addDiagConstraint(arr, y * config.gridWidth + x, (y + 1) * config.gridWidth + (x + 1)); + } + } + + for (let y = 0; y < config.gridHeight - 1; y++) { + const arr = (y % 2 === 0) ? constraintsP6 : constraintsP7; + for (let x = 0; x < config.gridWidth - 1; x++) { + addDiagConstraint(arr, y * config.gridWidth + (x + 1), (y + 1) * config.gridWidth + x); + } + } + + return { + positions: positionsData, + prevPositions: prevPositionsData, + velocities: velocitiesData, + constraints: [ + constraintsP0, constraintsP1, constraintsP2, constraintsP3, + constraintsP4, constraintsP5, constraintsP6, constraintsP7 + ], + params: new Float32Array(9) + }; + } + + private setupRenderMesh(scene: Scene, config: ClothConfig): void { + if (this.clothMesh) { + scene.removeMesh(this.clothMesh); + } + + this.clothMesh = MeshBuilder.CreateGround("cloth", { + width: 10, + height: 10, + subdivisions: config.gridWidth - 1, + updatable: true + }, scene); + + const clothMaterial = new ShaderMaterial("clothMat", scene, { + vertexSource: CLOTH_VERTEX_SHADER_GLSL, + fragmentSource: CLOTH_FRAGMENT_SHADER_GLSL + }, { + attributes: ["position", "uv"], + uniforms: ["viewProjection"] + }); + + clothMaterial.backFaceCulling = false; + this.clothMesh.material = clothMaterial; + + const camera = scene.activeCamera as ArcRotateCamera; + if (camera) { + camera.alpha = Math.PI / 4; + camera.beta = Math.PI / 2.5; + camera.radius = 15; + } + } + + /** + * Extracts xyz from the vec4 positions array into a vec3 array for mesh vertex update. + */ + private extractPositionsVec3(positions: Float32Array, numVertices: number): Float32Array { + const result = new Float32Array(numVertices * 3); + for (let i = 0; i < numVertices; i++) { + result[i * 3 + 0] = positions[i * 4 + 0]; + result[i * 3 + 1] = positions[i * 4 + 1]; + result[i * 3 + 2] = positions[i * 4 + 2]; + } + return result; + } + + private startRenderLoop(scene: Scene, config: ClothConfig): void { + const substeps = 15; + + scene.onBeforeRenderObservable.clear(); + scene.onBeforeRenderObservable.add(() => { + if (!this.clothMesh) { + return; + } + + integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams); + + for (let i = 0; i < substeps; i++) { + for (const phase of this.constraintPhases) { + solveConstraints(this.positions, phase, this.physicsParams); + } + } + + updateVelocities(this.positions, this.prevPositions, this.velocities, this.physicsParams); + + const posVec3 = this.extractPositionsVec3(this.positions, config.numVertices); + this.clothMesh.updateVerticesData(VertexBuffer.PositionKind, posVec3); + }); + } +} diff --git a/src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts b/src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts new file mode 100644 index 0000000..822f9ca --- /dev/null +++ b/src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts @@ -0,0 +1,275 @@ +import {ArcRotateCamera, ComputeShader, Engine, GroundMesh, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core'; +import {ClothConfig, ClothData} from '../cloth.model'; +import { + CLOTH_FRAGMENT_SHADER_WGSL, + CLOTH_INTEGRATE_COMPUTE_WGSL, + CLOTH_SOLVE_COMPUTE_WGSL, + CLOTH_VELOCITY_COMPUTE_WGSL, + CLOTH_VERTEX_SHADER_WGSL +} from '../cloth.shader'; +import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy'; + +interface GpuBuffers { + positions: StorageBuffer; + prevPositions: StorageBuffer; + velocities: StorageBuffer; + params: StorageBuffer; + constraints: StorageBuffer[]; +} + +interface GpuPipelines { + integrate: ComputeShader; + solvers: ComputeShader[]; + velocity: ComputeShader; +} + +export class ClothGpuStrategy implements ClothSimulationStrategy { + private clothMesh: GroundMesh | null = null; + private scene: Scene | null = null; + private paramsData = new Float32Array(9); + private buffers: GpuBuffers | null = null; + private pipelines: GpuPipelines | null = null; + private dispatchXConstraints: number[] = []; + private dispatchXVertices = 0; + private numVertices = 0; + + init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void { + this.scene = scene; + this.numVertices = config.numVertices; + const gpuEngine = engine as WebGPUEngine; + + const clothData = this.generateClothData(config); + this.buffers = this.createStorageBuffers(gpuEngine, clothData); + this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers); + this.setupRenderMesh(scene, config, this.buffers.positions); + this.setupDispatchSizes(config, this.buffers); + this.startRenderLoop(scene); + } + + updateParams(params: ClothSimulationParams): void { + if (!this.buffers) { + return; + } + + const windX = params.isWindActive ? 5.0 : 0.0; + const windZ = params.isWindActive ? 15.0 : 0.0; + + const softCompliance = 10.0; + const rigidCompliance = 0.00001; + const t = (params.stiffness - 1) / 99.0; + const compliance = softCompliance * Math.pow(rigidCompliance / softCompliance, t); + + this.paramsData[0] = 0.016; + this.paramsData[1] = -9.81; + this.paramsData[2] = compliance; + this.paramsData[3] = this.numVertices; + this.paramsData[4] = windX; + this.paramsData[5] = 0.0; + this.paramsData[6] = windZ; + this.paramsData[7] = params.simulationTime; + this.paramsData[8] = params.elongation; + } + + getMesh(): GroundMesh | null { + return this.clothMesh; + } + + dispose(): void { + if (this.scene && this.clothMesh) { + this.scene.removeMesh(this.clothMesh); + } + this.clothMesh = null; + this.buffers = null; + this.pipelines = null; + } + + private generateClothData(config: ClothConfig): ClothData { + const positionsData = new Float32Array(config.numVertices * 4); + const prevPositionsData = new Float32Array(config.numVertices * 4); + const velocitiesData = new Float32Array(config.numVertices * 4); + + const constraintsP0: number[] = []; + const constraintsP1: number[] = []; + const constraintsP2: number[] = []; + const constraintsP3: number[] = []; + + const addHorizontalConstraint = (arr: number[], a: number, b: number): void => { + arr.push(a, b, config.spacing, 1.0); + }; + const addVerticalConstraint = (arr: number[], a: number, b: number): void => { + arr.push(a, b, config.spacing, 2.0); + }; + + for (let y = 0; y < config.gridHeight; y++) { + for (let x = 0; x < config.gridWidth; x++) { + const idx = (y * config.gridWidth + x) * 4; + positionsData[idx + 0] = (x - config.gridWidth / 2) * config.spacing; + positionsData[idx + 1] = 5.0 - (y * config.spacing); + positionsData[idx + 2] = 0.0; + positionsData[idx + 3] = (y === 0) ? 0.0 : config.particleInvMass; + + prevPositionsData[idx + 0] = positionsData[idx + 0]; + prevPositionsData[idx + 1] = positionsData[idx + 1]; + prevPositionsData[idx + 2] = positionsData[idx + 2]; + prevPositionsData[idx + 3] = positionsData[idx + 3]; + } + } + + for (let y = 0; y < config.gridHeight; y++) { + for (let x = 0; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP0, y * config.gridWidth + x, y * config.gridWidth + x + 1); } + for (let x = 1; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP1, y * config.gridWidth + x, y * config.gridWidth + x + 1); } + } + for (let y = 0; y < config.gridHeight - 1; y += 2) { + for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP2, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); } + } + for (let y = 1; y < config.gridHeight - 1; y += 2) { + for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP3, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); } + } + + const constraintsP4: number[] = []; + const constraintsP5: number[] = []; + const constraintsP6: number[] = []; + const constraintsP7: number[] = []; + const diagSpacing = config.spacing * Math.SQRT2; + const addDiagConstraint = (arr: number[], a: number, b: number): void => { + arr.push(a, b, diagSpacing, 1.0); + }; + + for (let y = 0; y < config.gridHeight - 1; y++) { + const arr = (y % 2 === 0) ? constraintsP4 : constraintsP5; + for (let x = 0; x < config.gridWidth - 1; x++) { + addDiagConstraint(arr, y * config.gridWidth + x, (y + 1) * config.gridWidth + (x + 1)); + } + } + + for (let y = 0; y < config.gridHeight - 1; y++) { + const arr = (y % 2 === 0) ? constraintsP6 : constraintsP7; + for (let x = 0; x < config.gridWidth - 1; x++) { + addDiagConstraint(arr, y * config.gridWidth + (x + 1), (y + 1) * config.gridWidth + x); + } + } + + return { + positions: positionsData, + prevPositions: prevPositionsData, + velocities: velocitiesData, + constraints: [ + constraintsP0, constraintsP1, constraintsP2, constraintsP3, + constraintsP4, constraintsP5, constraintsP6, constraintsP7 + ], + params: new Float32Array(9) + }; + } + + private createStorageBuffers(engine: WebGPUEngine, data: ClothData): GpuBuffers { + const createBuffer = (arrayData: Float32Array | number[]): StorageBuffer => { + const buffer = new StorageBuffer(engine, arrayData.length * 4); + buffer.update(arrayData instanceof Float32Array ? arrayData : new Float32Array(arrayData)); + return buffer; + }; + + return { + positions: createBuffer(data.positions), + prevPositions: createBuffer(data.prevPositions), + velocities: createBuffer(data.velocities), + params: createBuffer(data.params), + constraints: data.constraints.map(cData => createBuffer(cData)) + }; + } + + private setupComputePipelines(engine: WebGPUEngine, buffers: GpuBuffers): GpuPipelines { + const createBasicShader = (name: string, source: string) => { + const cs = new ComputeShader(name, engine, {computeSource: source}, { + bindingsMapping: { + "p": {group: 0, binding: 0}, + "positions": {group: 0, binding: 1}, + "prev_positions": {group: 0, binding: 2}, + "velocities": {group: 0, binding: 3} + } + }); + cs.setStorageBuffer("p", buffers.params); + cs.setStorageBuffer("positions", buffers.positions); + cs.setStorageBuffer("prev_positions", buffers.prevPositions); + cs.setStorageBuffer("velocities", buffers.velocities); + return cs; + }; + + const createSolverShader = (name: string, constraintBuffer: StorageBuffer) => { + const cs = new ComputeShader(name, engine, {computeSource: CLOTH_SOLVE_COMPUTE_WGSL}, { + bindingsMapping: { + "p": {group: 0, binding: 0}, + "positions": {group: 0, binding: 1}, + "constraints": {group: 0, binding: 2} + } + }); + cs.setStorageBuffer("p", buffers.params); + cs.setStorageBuffer("positions", buffers.positions); + cs.setStorageBuffer("constraints", constraintBuffer); + return cs; + }; + + return { + integrate: createBasicShader("integrate", CLOTH_INTEGRATE_COMPUTE_WGSL), + solvers: buffers.constraints.map((cBuffer, i) => createSolverShader(`solve${i}`, cBuffer)), + velocity: createBasicShader("velocity", CLOTH_VELOCITY_COMPUTE_WGSL) + }; + } + + private setupRenderMesh(scene: Scene, config: ClothConfig, positionsBuffer: StorageBuffer): void { + if (this.clothMesh) { + scene.removeMesh(this.clothMesh); + } + + this.clothMesh = MeshBuilder.CreateGround("cloth", {width: 10, height: 10, subdivisions: config.gridWidth - 1}, scene); + + const clothMaterial = new ShaderMaterial("clothMat", scene, { + vertexSource: CLOTH_VERTEX_SHADER_WGSL, + fragmentSource: CLOTH_FRAGMENT_SHADER_WGSL + }, { + attributes: ["position", "uv"], + uniforms: ["viewProjection"], + storageBuffers: ["positions"], + shaderLanguage: ShaderLanguage.WGSL + }); + + clothMaterial.backFaceCulling = false; + clothMaterial.setStorageBuffer("positions", positionsBuffer); + this.clothMesh.material = clothMaterial; + + const camera = scene.activeCamera as ArcRotateCamera; + if (camera) { + camera.alpha = Math.PI / 4; + camera.beta = Math.PI / 2.5; + camera.radius = 15; + } + } + + private setupDispatchSizes(config: ClothConfig, buffers: GpuBuffers): void { + const constraintsLength = buffers.constraints.map(b => (b as any)._buffer.capacity / 4 / 4); + this.dispatchXConstraints = constraintsLength.map(len => Math.ceil(len / 64)); + this.dispatchXVertices = Math.ceil(config.numVertices / 64); + } + + private startRenderLoop(scene: Scene): void { + const substeps = 15; + + scene.onBeforeRenderObservable.clear(); + scene.onBeforeRenderObservable.add(() => { + if (!this.buffers || !this.pipelines) { + return; + } + + this.buffers.params.update(this.paramsData); + + this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1); + + for (let i = 0; i < substeps; i++) { + for (let phase = 0; phase < this.pipelines.solvers.length; phase++) { + this.pipelines.solvers[phase].dispatch(this.dispatchXConstraints[phase], 1, 1); + } + } + + this.pipelines.velocity.dispatch(this.dispatchXVertices, 1, 1); + }); + } +} diff --git a/src/app/pages/algorithms/cloth/strategies/cloth-simulation.strategy.ts b/src/app/pages/algorithms/cloth/strategies/cloth-simulation.strategy.ts new file mode 100644 index 0000000..46d3d50 --- /dev/null +++ b/src/app/pages/algorithms/cloth/strategies/cloth-simulation.strategy.ts @@ -0,0 +1,17 @@ +import {Engine, GroundMesh, Scene, WebGPUEngine} from '@babylonjs/core'; +import {ClothConfig} from '../cloth.model'; + +export interface ClothSimulationParams { + stiffness: number; + elongation: number; + isWindActive: boolean; + simulationTime: number; + deltaTime: number; +} + +export interface ClothSimulationStrategy { + init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void; + updateParams(params: ClothSimulationParams): void; + getMesh(): GroundMesh | null; + dispose(): void; +} diff --git a/src/app/pages/algorithms/pendulum/pendulum-glsl.shader.ts b/src/app/pages/algorithms/pendulum/pendulum-glsl.shader.ts new file mode 100644 index 0000000..6aca0a2 --- /dev/null +++ b/src/app/pages/algorithms/pendulum/pendulum-glsl.shader.ts @@ -0,0 +1,134 @@ +/** + * GLSL shaders for pendulum rendering on WebGL. + * Replicates the visual output of the WGSL pendulum compute+fragment shaders. + * Uses a feedback texture (ping-pong) for trail persistence. + * + * Coordinate note: WGSL pixel buffer has Y=0 at top (screen space). + * GLSL UVs have Y=0 at bottom. We flip Y via (1.0 - uv.y) to match. + */ + +export const PENDULUM_VERTEX_SHADER_GLSL = ` + precision highp float; + + attribute vec3 position; + attribute vec2 uv; + + varying vec2 vUV; + + void main() { + vUV = uv; + gl_Position = vec4(position, 1.0); + } +`; + +/** + * Shader for the feedback pass: renders pendulum state to a render target texture. + * The R channel stores trail1 intensity, G stores trail2 intensity. + */ +export const PENDULUM_FEEDBACK_FRAGMENT_GLSL = ` + precision highp float; + + uniform vec2 resolution; + uniform float theta1; + uniform float theta2; + uniform float l1; + uniform float l2; + uniform float trailDecay; + uniform sampler2D previousFrame; + + varying vec2 vUV; + + void main() { + // Flip Y to match WGSL screen-space (Y=0 at top) + vec2 uv = vec2(vUV.x, 1.0 - vUV.y); + float aspect = resolution.x / resolution.y; + vec2 uvCorrected = vec2(uv.x * aspect, uv.y); + + vec4 prev = texture2D(previousFrame, vUV); + float trail1 = prev.r * trailDecay; + float trail2 = prev.g * trailDecay; + + vec2 origin = vec2(0.5 * aspect, 0.3); + float displayScale = 0.15; + + vec2 p1 = origin + vec2(sin(theta1), cos(theta1)) * l1 * displayScale; + vec2 p2 = p1 + vec2(sin(theta2), cos(theta2)) * l2 * displayScale; + + float dMass1 = length(uvCorrected - p1); + float dMass2 = length(uvCorrected - p2); + + if (dMass1 < 0.02) { + trail1 = 1.0; + } + if (dMass2 < 0.02) { + trail2 = 1.0; + } + + gl_FragColor = vec4(trail1, trail2, 0.0, 1.0); + } +`; + +/** + * Display shader: reads trail data from feedback texture and renders final colors. + */ +export const PENDULUM_DISPLAY_FRAGMENT_GLSL = ` + precision highp float; + + uniform vec2 resolution; + uniform float theta1; + uniform float theta2; + uniform float l1; + uniform float l2; + uniform sampler2D trailTexture; + + varying vec2 vUV; + + float sdSegment(vec2 p, vec2 a, vec2 b) { + vec2 pa = p - a; + vec2 ba = b - a; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + return length(pa - ba * h); + } + + void main() { + // Flip Y to match WGSL screen-space (Y=0 at top) + vec2 uv = vec2(vUV.x, 1.0 - vUV.y); + float aspect = resolution.x / resolution.y; + vec2 uvCorrected = vec2(uv.x * aspect, uv.y); + + vec4 trailData = texture2D(trailTexture, vUV); + float trail1 = trailData.r; + float trail2 = trailData.g; + + vec2 origin = vec2(0.5 * aspect, 0.3); + float displayScale = 0.15; + + vec2 p1 = origin + vec2(sin(theta1), cos(theta1)) * l1 * displayScale; + vec2 p2 = p1 + vec2(sin(theta2), cos(theta2)) * l2 * displayScale; + + float dLine1 = sdSegment(uvCorrected, origin, p1); + float dLine2 = sdSegment(uvCorrected, p1, p2); + float dMass1 = length(uvCorrected - p1); + float dMass2 = length(uvCorrected - p2); + + vec3 bgColor = vec3(0.1, 0.1, 0.15); + vec3 mass1Color = vec3(1.0, 0.0, 0.0); + vec3 mass2Color = vec3(0.0, 1.0, 0.0); + vec3 line1Color = vec3(1.0, 1.0, 0.0); + vec3 line2Color = vec3(1.0, 0.0, 1.0); + + vec3 finalColor = bgColor; + finalColor = mix(finalColor, mass1Color, clamp(trail1, 0.0, 1.0)); + finalColor = mix(finalColor, mass2Color, clamp(trail2, 0.0, 1.0)); + + if (dMass1 >= 0.02 && dMass2 >= 0.02) { + if (dLine1 < 0.003) { + finalColor = line1Color; + } else if (dLine2 < 0.003) { + finalColor = line2Color; + } + } + + gl_FragColor = vec4(finalColor, 1.0); + } +`; diff --git a/src/app/pages/algorithms/pendulum/pendulum.component.ts b/src/app/pages/algorithms/pendulum/pendulum.component.ts index 67f0d78..dcc3497 100644 --- a/src/app/pages/algorithms/pendulum/pendulum.component.ts +++ b/src/app/pages/algorithms/pendulum/pendulum.component.ts @@ -1,8 +1,7 @@ import {Component} from '@angular/core'; import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; -import {ComputeShader, ShaderLanguage, StorageBuffer} from '@babylonjs/core'; -import {PENDULUM_FRAGMENT_SHADER_WGSL, PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL, PENDULUM_RENDER_COMPUTE_SHADER_WGSL, PENDULUM_VERTEX_SHADER_WGSL} from './pendulum.shader'; + import {FormsModule} from '@angular/forms'; import {NgxSliderModule, Options} from '@angular-slider/ngx-slider'; import {DEFAULT_DAMPING, DEFAULT_G, DEFAULT_L1_LENGTH, DEFAULT_M1_MASS, DEFAULT_L2_LENGTH, DEFAULT_M2_MASS, DEFAULT_TRAIL_DECAY, MAX_DAMPING, MAX_G, MAX_LENGTH, MAX_MASS, MAX_TRAIL_DECAY, MIN_DAMPING, MIN_G, MIN_LENGTH, MIN_MASS, MIN_TRAIL_DECAY, IMPULSE_M2, IMPULSE_M1} from './pendulum.model'; @@ -11,6 +10,9 @@ import {MatButton} from '@angular/material/button'; import {Information} from '../information/information'; import {AlgorithmInformation} from '../information/information.models'; import {UrlConstants} from '../../../constants/UrlConstants'; +import {PendulumSimulationStrategy} from './strategies/pendulum-simulation.strategy'; +import {PendulumGpuStrategy} from './strategies/pendulum-gpu.strategy'; +import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy'; @Component({ selector: 'app-pendulum', @@ -31,7 +33,6 @@ import {UrlConstants} from '../../../constants/UrlConstants'; }) class PendulumComponent { - // --- CONFIGURATION --- algoInformation: AlgorithmInformation = { title: 'PENDULUM.EXPLANATION.TITLE', entries: [ @@ -46,15 +47,9 @@ class PendulumComponent { disclaimerListEntry: ['PENDULUM.EXPLANATION.DISCLAIMER_1', 'PENDULUM.EXPLANATION.DISCLAIMER_2', 'PENDULUM.EXPLANATION.DISCLAIMER_3', 'PENDULUM.EXPLANATION.DISCLAIMER_4'] }; - renderConfig: RenderConfig = { mode: '2D', - initialViewSize: 2, - shaderLanguage: ShaderLanguage.WGSL, - vertexShader: PENDULUM_VERTEX_SHADER_WGSL, - fragmentShader: PENDULUM_FRAGMENT_SHADER_WGSL, - uniformNames: [], - uniformBufferNames: [] + initialViewSize: 2 }; trailDecayOptions: Options = { @@ -107,7 +102,6 @@ class PendulumComponent { hidePointerLabels: false }; - // Central management of physics parameters readonly simParams = { time: 0, dt: 0.015, @@ -123,6 +117,7 @@ class PendulumComponent { }; private currentSceneData: SceneEventData | null = null; + private strategy: PendulumSimulationStrategy | null = null; onSceneReady(event: SceneEventData) { this.currentSceneData = event; @@ -130,83 +125,34 @@ class PendulumComponent { } private createSimulation() { - if (!this.currentSceneData){ + if (!this.currentSceneData) { return; } - const {engine, scene} = this.currentSceneData; - engine.resize(); - const width = engine.getRenderWidth(); - const height = engine.getRenderHeight(); - const totalPixels = width * height; + const {engine, scene, gpuTier} = this.currentSceneData; - // --- 1. BUFFERS --- - const pixelBuffer = new StorageBuffer(engine, totalPixels * 4); - - const stateBuffer = new StorageBuffer(engine, 4 * 4); - stateBuffer.update(new Float32Array([Math.PI / 4, Math.PI / 2, 0, 0])); // Initial angles - - const paramsBuffer = new StorageBuffer(engine, 14 * 4); - const paramsData = new Float32Array(14); - - // --- 2. SHADERS --- - const csPhysics = new ComputeShader("physics", engine, - {computeSource: PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL}, - {bindingsMapping: {"state": {group: 0, binding: 0}, "p": {group: 0, binding: 1}}} - ); - csPhysics.setStorageBuffer("state", stateBuffer); - csPhysics.setStorageBuffer("p", paramsBuffer); - - const csRender = new ComputeShader("render", engine, - {computeSource: PENDULUM_RENDER_COMPUTE_SHADER_WGSL}, - {bindingsMapping: {"pixelBuffer": {group: 0, binding: 0}, "p": {group: 0, binding: 1}, "state": {group: 0, binding: 2}}} - ); - csRender.setStorageBuffer("pixelBuffer", pixelBuffer); - csRender.setStorageBuffer("p", paramsBuffer); - csRender.setStorageBuffer("state", stateBuffer); - - // --- 3. MATERIAL --- - const plane = scene.getMeshByName("plane"); - if (plane?.material) { - const mat = plane.material as any; - mat.setStorageBuffer("pixelBuffer", pixelBuffer); - mat.setStorageBuffer("p", paramsBuffer); + if (this.strategy) { + this.strategy.dispose(); } - //remove old observables if available - scene.onBeforeRenderObservable.clear(); - // --- 4. RENDER LOOP --- - scene.onBeforeRenderObservable.add(() => { + this.strategy = gpuTier === 'webgpu' + ? new PendulumGpuStrategy() + : new PendulumCpuStrategy(); + + this.strategy.init(scene, engine); + this.startParamUpdateLoop(scene); + } + + private startParamUpdateLoop(scene: any): void { + scene.onAfterRenderObservable.clear(); + scene.onAfterRenderObservable.add(() => { this.simParams.time += this.simParams.dt; - const currentWidth = engine.getRenderWidth(); - const currentHeight = engine.getRenderHeight(); - - // Fill parameter array (must match the exact order of the WGSL struct!) - paramsData[0] = currentWidth; - paramsData[1] = currentHeight; - paramsData[2] = this.simParams.time; - paramsData[3] = this.simParams.dt; - paramsData[4] = this.simParams.g; - paramsData[5] = this.simParams.m1; - paramsData[6] = this.simParams.m2; - paramsData[7] = this.simParams.l1; - paramsData[8] = this.simParams.l2; - paramsData[9] = this.simParams.damping; - paramsData[10] = this.simParams.trailDecay; - paramsData[11] = this.simParams.impulseM1; - paramsData[12] = this.simParams.impulseM2; - paramsData[13] = 0; // Pad + if (this.strategy) { + this.strategy.updateParams({...this.simParams}); + } this.resetImpulses(); - - paramsBuffer.update(paramsData); - - // Trigger simulation and rendering - csPhysics.dispatch(1, 1, 1); - - const dispatchCount = Math.ceil((currentWidth * currentHeight) / 64); - csRender.dispatch(dispatchCount, 1, 1); }); } @@ -221,8 +167,7 @@ class PendulumComponent { } pushPendulum(m1: boolean) { - if (m1) - { + if (m1) { this.simParams.impulseM1 = IMPULSE_M1; return; } @@ -235,6 +180,4 @@ class PendulumComponent { } } - - -export default PendulumComponent +export default PendulumComponent; diff --git a/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu-physics.ts b/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu-physics.ts new file mode 100644 index 0000000..3cdaf27 --- /dev/null +++ b/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu-physics.ts @@ -0,0 +1,50 @@ +/** + * CPU-side double pendulum physics mirroring PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL. + * Equations from: https://en.wikipedia.org/wiki/Double_pendulum + */ + +export interface PendulumState { + theta1: number; + theta2: number; + v1: number; + v2: number; +} + +export interface PendulumPhysicsParams { + dt: number; + g: number; + m1: number; + m2: number; + l1: number; + l2: number; + damping: number; + impulseM1: number; + impulseM2: number; +} + +export function stepPendulumPhysics(state: PendulumState, params: PendulumPhysicsParams): void { + const t1 = state.theta1; + const t2 = state.theta2; + const v1 = state.v1; + const v2 = state.v2; + + const deltaT = t1 - t2; + + const num1 = -params.g * (2.0 * params.m1 + params.m2) * Math.sin(t1) + - params.m2 * params.g * Math.sin(t1 - 2.0 * t2) + - 2.0 * Math.sin(deltaT) * params.m2 * (v2 * v2 * params.l2 + v1 * v1 * params.l1 * Math.cos(deltaT)); + const den1 = params.l1 * (2.0 * params.m1 + params.m2 - params.m2 * Math.cos(2.0 * deltaT)); + const a1 = num1 / den1; + + const num2 = 2.0 * Math.sin(deltaT) * (v1 * v1 * params.l1 * (params.m1 + params.m2) + params.g * (params.m1 + params.m2) * Math.cos(t1) + v2 * v2 * params.l2 * params.m2 * Math.cos(deltaT)); + const den2 = params.l2 * (2.0 * params.m1 + params.m2 - params.m2 * Math.cos(2.0 * deltaT)); + const a2 = num2 / den2; + + const newV1 = (v1 + a1 * params.dt) * params.damping + params.impulseM1; + const newV2 = (v2 + a2 * params.dt) * params.damping + params.impulseM2; + + state.v1 = newV1; + state.v2 = newV2; + state.theta1 = t1 + newV1 * params.dt; + state.theta2 = t2 + newV2 * params.dt; +} diff --git a/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu.strategy.ts b/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu.strategy.ts new file mode 100644 index 0000000..3b897ec --- /dev/null +++ b/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu.strategy.ts @@ -0,0 +1,134 @@ +import {Constants, Engine, MeshBuilder, RenderTargetTexture, Scene, ShaderMaterial, Texture, Vector2, WebGPUEngine} from '@babylonjs/core'; +import {PENDULUM_DISPLAY_FRAGMENT_GLSL, PENDULUM_FEEDBACK_FRAGMENT_GLSL, PENDULUM_VERTEX_SHADER_GLSL} from '../pendulum-glsl.shader'; +import {PendulumState, stepPendulumPhysics} from './pendulum-cpu-physics'; +import {PendulumSimParams, PendulumSimulationStrategy} from './pendulum-simulation.strategy'; + +export class PendulumCpuStrategy implements PendulumSimulationStrategy { + private scene: Scene | null = null; + private state: PendulumState = {theta1: Math.PI / 4, theta2: Math.PI / 2, v1: 0, v2: 0}; + private params: PendulumSimParams | null = null; + private feedbackMaterial: ShaderMaterial | null = null; + private displayMaterial: ShaderMaterial | null = null; + private rttA: RenderTargetTexture | null = null; + private rttB: RenderTargetTexture | null = null; + private pingPong = false; + + init(scene: Scene, engine: WebGPUEngine | Engine): void { + this.scene = scene; + + const existingPlane = scene.getMeshByName("plane"); + if (existingPlane) { + scene.removeMesh(existingPlane); + } + + const width = engine.getRenderWidth(); + const height = engine.getRenderHeight(); + + this.rttA = new RenderTargetTexture("rttA", {width, height}, scene, false, true, Constants.TEXTURETYPE_FLOAT); + this.rttB = new RenderTargetTexture("rttB", {width, height}, scene, false, true, Constants.TEXTURETYPE_FLOAT); + + this.rttA.wrapU = Texture.CLAMP_ADDRESSMODE; + this.rttA.wrapV = Texture.CLAMP_ADDRESSMODE; + this.rttB.wrapU = Texture.CLAMP_ADDRESSMODE; + this.rttB.wrapV = Texture.CLAMP_ADDRESSMODE; + + // Size 2 maps positions to -1..1 which fills clip space (vertex shader bypasses camera) + const feedbackPlane = MeshBuilder.CreatePlane("feedbackPlane", {size: 2}, scene); + feedbackPlane.alwaysSelectAsActiveMesh = true; + // Use layer mask to exclude from main scene rendering (only rendered via RTT) + feedbackPlane.layerMask = 0x10000000; + + this.feedbackMaterial = new ShaderMaterial("feedbackMat", scene, { + vertexSource: PENDULUM_VERTEX_SHADER_GLSL, + fragmentSource: PENDULUM_FEEDBACK_FRAGMENT_GLSL + }, { + attributes: ["position", "uv"], + uniforms: ["resolution", "theta1", "theta2", "l1", "l2", "trailDecay"], + samplers: ["previousFrame"] + }); + this.feedbackMaterial.backFaceCulling = false; + feedbackPlane.material = this.feedbackMaterial; + + this.rttA.renderList!.push(feedbackPlane); + this.rttB.renderList!.push(feedbackPlane); + + // Size 2 maps positions to -1..1 which fills clip space (vertex shader bypasses camera) + const displayPlane = MeshBuilder.CreatePlane("displayPlane", {size: 2}, scene); + displayPlane.alwaysSelectAsActiveMesh = true; + + this.displayMaterial = new ShaderMaterial("displayMat", scene, { + vertexSource: PENDULUM_VERTEX_SHADER_GLSL, + fragmentSource: PENDULUM_DISPLAY_FRAGMENT_GLSL + }, { + attributes: ["position", "uv"], + uniforms: ["resolution", "theta1", "theta2", "l1", "l2"], + samplers: ["trailTexture"] + }); + this.displayMaterial.backFaceCulling = false; + this.displayMaterial.disableDepthWrite = true; + displayPlane.material = this.displayMaterial; + + // RTTs are rendered manually via writeTarget.render() -- do NOT add to customRenderTargets + this.startRenderLoop(scene, engine, width, height); + } + + updateParams(params: PendulumSimParams): void { + this.params = params; + } + + dispose(): void { + if (this.scene) { + this.scene.onBeforeRenderObservable.clear(); + } + this.rttA?.dispose(); + this.rttB?.dispose(); + this.feedbackMaterial?.dispose(); + this.displayMaterial?.dispose(); + this.scene = null; + } + + private startRenderLoop(scene: Scene, engine: WebGPUEngine | Engine, width: number, height: number): void { + const resolution = new Vector2(width, height); + + scene.onBeforeRenderObservable.clear(); + scene.onBeforeRenderObservable.add(() => { + if (!this.params || !this.feedbackMaterial || !this.displayMaterial || !this.rttA || !this.rttB) { + return; + } + + stepPendulumPhysics(this.state, { + dt: this.params.dt, + g: this.params.g, + m1: this.params.m1, + m2: this.params.m2, + l1: this.params.l1, + l2: this.params.l2, + damping: this.params.damping, + impulseM1: this.params.impulseM1, + impulseM2: this.params.impulseM2 + }); + + const readTarget = this.pingPong ? this.rttA : this.rttB; + const writeTarget = this.pingPong ? this.rttB : this.rttA; + + this.feedbackMaterial.setTexture("previousFrame", readTarget); + this.feedbackMaterial.setVector2("resolution", resolution); + this.feedbackMaterial.setFloat("theta1", this.state.theta1); + this.feedbackMaterial.setFloat("theta2", this.state.theta2); + this.feedbackMaterial.setFloat("l1", this.params.l1); + this.feedbackMaterial.setFloat("l2", this.params.l2); + this.feedbackMaterial.setFloat("trailDecay", this.params.trailDecay); + + writeTarget.render(); + + this.displayMaterial.setTexture("trailTexture", writeTarget); + this.displayMaterial.setVector2("resolution", resolution); + this.displayMaterial.setFloat("theta1", this.state.theta1); + this.displayMaterial.setFloat("theta2", this.state.theta2); + this.displayMaterial.setFloat("l1", this.params.l1); + this.displayMaterial.setFloat("l2", this.params.l2); + + this.pingPong = !this.pingPong; + }); + } +} diff --git a/src/app/pages/algorithms/pendulum/strategies/pendulum-gpu.strategy.ts b/src/app/pages/algorithms/pendulum/strategies/pendulum-gpu.strategy.ts new file mode 100644 index 0000000..0546c0e --- /dev/null +++ b/src/app/pages/algorithms/pendulum/strategies/pendulum-gpu.strategy.ts @@ -0,0 +1,112 @@ +import {Camera, ComputeShader, Engine, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core'; +import {PENDULUM_FRAGMENT_SHADER_WGSL, PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL, PENDULUM_RENDER_COMPUTE_SHADER_WGSL, PENDULUM_VERTEX_SHADER_WGSL} from '../pendulum.shader'; +import {PendulumSimParams, PendulumSimulationStrategy} from './pendulum-simulation.strategy'; + +export class PendulumGpuStrategy implements PendulumSimulationStrategy { + private scene: Scene | null = null; + private paramsData = new Float32Array(14); + private paramsBuffer: StorageBuffer | null = null; + + init(scene: Scene, engine: WebGPUEngine | Engine): void { + this.scene = scene; + const gpuEngine = engine as WebGPUEngine; + gpuEngine.resize(); + + const width = gpuEngine.getRenderWidth(); + const height = gpuEngine.getRenderHeight(); + const totalPixels = width * height; + + const pixelBuffer = new StorageBuffer(gpuEngine, totalPixels * 4); + + const stateBuffer = new StorageBuffer(gpuEngine, 4 * 4); + stateBuffer.update(new Float32Array([Math.PI / 4, Math.PI / 2, 0, 0])); + + this.paramsBuffer = new StorageBuffer(gpuEngine, 14 * 4); + + const csPhysics = new ComputeShader("physics", gpuEngine, + {computeSource: PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL}, + {bindingsMapping: {"state": {group: 0, binding: 0}, "p": {group: 0, binding: 1}}} + ); + csPhysics.setStorageBuffer("state", stateBuffer); + csPhysics.setStorageBuffer("p", this.paramsBuffer); + + const csRender = new ComputeShader("render", gpuEngine, + {computeSource: PENDULUM_RENDER_COMPUTE_SHADER_WGSL}, + {bindingsMapping: {"pixelBuffer": {group: 0, binding: 0}, "p": {group: 0, binding: 1}, "state": {group: 0, binding: 2}}} + ); + csRender.setStorageBuffer("pixelBuffer", pixelBuffer); + csRender.setStorageBuffer("p", this.paramsBuffer); + csRender.setStorageBuffer("state", stateBuffer); + + // Create WGSL display plane and material + const displayMaterial = new ShaderMaterial("pendulumWgslMat", scene, { + vertexSource: PENDULUM_VERTEX_SHADER_WGSL, + fragmentSource: PENDULUM_FRAGMENT_SHADER_WGSL + }, { + attributes: ["position"], + uniforms: [], + storageBuffers: ["pixelBuffer", "p"], + shaderLanguage: ShaderLanguage.WGSL + }); + displayMaterial.setStorageBuffer("pixelBuffer", pixelBuffer); + displayMaterial.setStorageBuffer("p", this.paramsBuffer); + displayMaterial.backFaceCulling = false; + displayMaterial.disableDepthWrite = true; + + const plane = MeshBuilder.CreatePlane("pendulumPlane", {size: 100}, scene); + const camera = scene.activeCamera as Camera; + if (camera) { + plane.lookAt(camera.position); + } + plane.alwaysSelectAsActiveMesh = true; + plane.material = displayMaterial; + + scene.onBeforeRenderObservable.clear(); + scene.onBeforeRenderObservable.add(() => { + if (!this.paramsBuffer) { + return; + } + + const currentWidth = gpuEngine.getRenderWidth(); + const currentHeight = gpuEngine.getRenderHeight(); + this.paramsData[0] = currentWidth; + this.paramsData[1] = currentHeight; + + this.paramsBuffer.update(this.paramsData); + + csPhysics.dispatch(1, 1, 1); + + const dispatchCount = Math.ceil((currentWidth * currentHeight) / 64); + csRender.dispatch(dispatchCount, 1, 1); + }); + } + + updateParams(params: PendulumSimParams): void { + // paramsData[0] (width) and paramsData[1] (height) are set in the render loop + this.paramsData[2] = params.time; + this.paramsData[3] = params.dt; + this.paramsData[4] = params.g; + this.paramsData[5] = params.m1; + this.paramsData[6] = params.m2; + this.paramsData[7] = params.l1; + this.paramsData[8] = params.l2; + this.paramsData[9] = params.damping; + this.paramsData[10] = params.trailDecay; + this.paramsData[11] = params.impulseM1; + this.paramsData[12] = params.impulseM2; + this.paramsData[13] = 0; + } + + dispose(): void { + if (this.scene) { + this.scene.onBeforeRenderObservable.clear(); + const plane = this.scene.getMeshByName("pendulumPlane"); + if (plane) { + this.scene.removeMesh(plane); + plane.dispose(); + } + } + this.paramsBuffer = null; + this.scene = null; + } +} diff --git a/src/app/pages/algorithms/pendulum/strategies/pendulum-simulation.strategy.ts b/src/app/pages/algorithms/pendulum/strategies/pendulum-simulation.strategy.ts new file mode 100644 index 0000000..8a84455 --- /dev/null +++ b/src/app/pages/algorithms/pendulum/strategies/pendulum-simulation.strategy.ts @@ -0,0 +1,21 @@ +import {Engine, Scene, WebGPUEngine} from '@babylonjs/core'; + +export interface PendulumSimParams { + time: number; + dt: number; + g: number; + m1: number; + m2: number; + l1: number; + l2: number; + damping: number; + trailDecay: number; + impulseM1: number; + impulseM2: number; +} + +export interface PendulumSimulationStrategy { + init(scene: Scene, engine: WebGPUEngine | Engine): void; + updateParams(params: PendulumSimParams): void; + dispose(): void; +} diff --git a/src/app/service/gpu-capability.service.ts b/src/app/service/gpu-capability.service.ts new file mode 100644 index 0000000..3964168 --- /dev/null +++ b/src/app/service/gpu-capability.service.ts @@ -0,0 +1,55 @@ +import {Injectable, signal} from '@angular/core'; + +export type GpuTier = 'webgpu' | 'webgl' | 'none'; + +@Injectable({providedIn: 'root'}) +export class GpuCapabilityService { + private cachedTier: GpuTier | null = null; + readonly tier = signal(null); + + async detect(): Promise { + if (this.cachedTier) { + return this.cachedTier; + } + + const result = await this.probe(); + this.cachedTier = result; + this.tier.set(result); + return result; + } + + private async probe(): Promise { + if (await this.isWebGpuAvailable()) { + return 'webgpu'; + } + + if (this.isWebGlAvailable()) { + return 'webgl'; + } + + return 'none'; + } + + private async isWebGpuAvailable(): Promise { + if (!navigator.gpu) { + return false; + } + + try { + const adapter = await navigator.gpu.requestAdapter(); + return adapter !== null; + } catch { + return false; + } + } + + private isWebGlAvailable(): boolean { + try { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('webgl2'); + return context !== null; + } catch { + return false; + } + } +} diff --git a/src/app/shared/components/render-canvas/babylon-canvas.component.ts b/src/app/shared/components/render-canvas/babylon-canvas.component.ts index f14940c..1c10b6b 100644 --- a/src/app/shared/components/render-canvas/babylon-canvas.component.ts +++ b/src/app/shared/components/render-canvas/babylon-canvas.component.ts @@ -1,7 +1,8 @@ import {AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, NgZone, OnDestroy, Output, ViewChild} from '@angular/core'; import {MatSnackBar} from '@angular/material/snack-bar'; import {TranslateService} from '@ngx-translate/core'; -import {ArcRotateCamera, Camera, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, Vector2, Vector3, WebGPUEngine} from '@babylonjs/core'; +import {ArcRotateCamera, Camera, Engine, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, Vector2, Vector3, WebGPUEngine} from '@babylonjs/core'; +import {GpuCapabilityService, GpuTier} from '../../../service/gpu-capability.service'; export interface RenderConfig { mode: '2D' | '3D'; @@ -17,7 +18,8 @@ export type RenderCallback = (material: ShaderMaterial, camera: Camera, canvas: export interface SceneEventData { scene: Scene; - engine: WebGPUEngine; + engine: WebGPUEngine | Engine; + gpuTier: GpuTier; } @Component({ @@ -30,6 +32,7 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy { readonly ngZone = inject(NgZone); private readonly snackBar = inject(MatSnackBar); private readonly translate = inject(TranslateService); + private readonly gpuCapability = inject(GpuCapabilityService); @ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef; @@ -38,11 +41,13 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy { @Output() sceneReady = new EventEmitter(); @Output() sceneResized = new EventEmitter(); + @Output() engineUnavailable = new EventEmitter<{ reason: string }>(); - private engine!: WebGPUEngine; + private engine!: WebGPUEngine | Engine; private scene!: Scene; private shaderMaterial!: ShaderMaterial; private camera!: Camera; + private gpuTier: GpuTier = 'none'; //Listener private readonly resizeHandler = () => this.handleResize(); @@ -63,26 +68,60 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy { private async initBabylon(): Promise { const canvas = this.canvasRef.nativeElement; - const tmpEngine = new WebGPUEngine(canvas); - await tmpEngine.initAsync().then(() => { - this.engine = tmpEngine; - this.scene = new Scene(this.engine); - this.setupCamera(canvas); - this.addListener(canvas); - this.createShaderMaterial(); - this.createFullScreenRect(); - this.sceneReady.emit({ - scene: this.scene, - engine: this.engine - }); - this.addRenderLoop(canvas); + const tier = await this.gpuCapability.detect(); + this.gpuTier = tier; - }) - .catch(() => { - const message = this.translate.instant('WEBGPU.NOT_SUPPORTED'); - this.snackBar.open(message, 'OK', { duration: 8000, horizontalPosition: "center", verticalPosition: "top" }); - this.engine = null!; - }); + if (tier === 'webgpu') { + await this.initWebGpuEngine(canvas); + return; + } + + if (tier === 'webgl') { + this.showSnackBar('GPU.WEBGL_FALLBACK'); + this.initWebGlEngine(canvas); + return; + } + + this.showSnackBar('GPU.NOT_SUPPORTED'); + this.engineUnavailable.emit({reason: 'no_gpu'}); + } + + private async initWebGpuEngine(canvas: HTMLCanvasElement): Promise { + const tmpEngine = new WebGPUEngine(canvas); + + try { + await tmpEngine.initAsync(); + this.engine = tmpEngine; + this.setupScene(canvas); + } catch { + this.showSnackBar('GPU.WEBGL_FALLBACK'); + this.gpuTier = 'webgl'; + this.initWebGlEngine(canvas); + } + } + + private initWebGlEngine(canvas: HTMLCanvasElement): void { + this.engine = new Engine(canvas, true); + this.setupScene(canvas); + } + + private setupScene(canvas: HTMLCanvasElement): void { + this.scene = new Scene(this.engine); + this.setupCamera(canvas); + this.addListener(canvas); + this.createShaderMaterial(); + this.createFullScreenRect(); + this.sceneReady.emit({ + scene: this.scene, + engine: this.engine, + gpuTier: this.gpuTier + }); + this.addRenderLoop(canvas); + } + + private showSnackBar(translationKey: string): void { + const message = this.translate.instant(translationKey); + this.snackBar.open(message, 'OK', {duration: 8000, horizontalPosition: 'center', verticalPosition: 'top'}); } private addListener(canvas: HTMLCanvasElement) { @@ -198,7 +237,8 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy { this.sceneResized?.emit({ scene: this.scene, - engine: this.engine + engine: this.engine, + gpuTier: this.gpuTier }); } } diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index 9f48aa4..aaf99d3 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -513,8 +513,9 @@ "DISCLAIMER_4": "Der XPBD-Kompromiss: XPBD umgeht dieses komplexe Matrix-Problem völlig, indem es als lokaler Löser arbeitet. Es kombiniert die unbedingte Stabilität eines impliziten Lösers mit der enormen Geschwindigkeit, Parallelisierbarkeit und dynamischen Anpassungsfähigkeit eines expliziten Systems." } }, - "WEBGPU": { - "NOT_SUPPORTED": "WebGPU konnte nicht gestartet werden. Bitte prüfe, ob dein Browser WebGPU unterstützt." + "GPU": { + "WEBGL_FALLBACK": "WebGPU ist nicht verfügbar. WebGL wird als Fallback verwendet. Die Leistung kann eingeschränkt sein.", + "NOT_SUPPORTED": "Weder WebGPU noch WebGL konnten initialisiert werden. GPU-Visualisierungen sind nicht verfügbar." }, "ALGORITHM": { "TITLE": "Algorithmen", diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index a177b22..3d5bc46 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -512,8 +512,9 @@ "DISCLAIMER_4": "The XPBD Compromise: XPBD completely bypasses this complex matrix problem by acting as a local solver. It combines the absolute stability of an implicit solver with the enormous speed, parallelizability, and dynamic adaptability of an explicit system." } }, - "WEBGPU": { - "NOT_SUPPORTED": "WebGPU could not be started. Please check if your browser supports WebGPU." + "GPU": { + "WEBGL_FALLBACK": "WebGPU is not available. Using WebGL as fallback. Performance may be reduced.", + "NOT_SUPPORTED": "Neither WebGPU nor WebGL could be initialized. GPU visualizations are unavailable." }, "ALGORITHM": { "TITLE": "Algorithms",