Updated the webgpu stuff to have webgl as fallback

This commit is contained in:
Andreas Dahm
2026-04-16 10:04:48 +02:00
parent 4e24cb5df1
commit a349f630c6
17 changed files with 1352 additions and 418 deletions

View File

@@ -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);
}
`;

View File

@@ -1,27 +1,17 @@
/** import {Component} from '@angular/core';
* File: cloth.component.ts import {FormsModule} from '@angular/forms';
* Description: Component for cloth simulation using WebGPU compute shaders. import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
*/ import {MatSliderModule} from '@angular/material/slider';
import {TranslatePipe} from '@ngx-translate/core';
import { Component } from '@angular/core'; import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
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 {MatButton} from '@angular/material/button'; 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 {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {UrlConstants} from '../../../constants/UrlConstants'; 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({ @Component({
selector: 'app-cloth', selector: 'app-cloth',
@@ -43,17 +33,16 @@ import {UrlConstants} from '../../../constants/UrlConstants';
export class ClothComponent { export class ClothComponent {
private currentSceneData: SceneEventData | null = null; private currentSceneData: SceneEventData | null = null;
private simulationTime: number = 0; private simulationTime: number = 0;
private clothMesh: GroundMesh | null = null; private strategy: ClothSimulationStrategy | null = null;
public isWindActive: boolean = false; public isWindActive: boolean = false;
public isOutlineActive: boolean = false; public isOutlineActive: boolean = false;
public stiffness: number = 80; public stiffness: number = 80;
// Elongation along the vertical (Y) axis, 0.5 = compressed, 2.0 = stretched
public elongation: number = 1.0; public elongation: number = 1.0;
public renderConfig: RenderConfig = { public renderConfig: RenderConfig = {
mode: '3D', mode: '3D',
initialViewSize: 20, initialViewSize: 20
shaderLanguage: ShaderLanguage.WGSL
}; };
algoInformation: AlgorithmInformation = { 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'] 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 { public onSceneReady(event: SceneEventData): void {
this.currentSceneData = event; this.currentSceneData = event;
this.createSimulation(); this.createSimulation();
@@ -104,10 +89,11 @@ export class ClothComponent {
public toggleMesh(): void { public toggleMesh(): void {
this.isOutlineActive = !this.isOutlineActive; this.isOutlineActive = !this.isOutlineActive;
if (!this.clothMesh?.material) { const mesh = this.strategy?.getMesh();
if (!mesh?.material) {
return; return;
} }
this.clothMesh.material.wireframe = this.isOutlineActive; mesh.material.wireframe = this.isOutlineActive;
} }
public restartSimulation(): void { public restartSimulation(): void {
@@ -115,36 +101,43 @@ export class ClothComponent {
this.createSimulation(); this.createSimulation();
} }
/**
* Initializes and starts the cloth simulation.
*/
private createSimulation(): void { 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(); const config = this.getClothConfig();
// 2. Generate initial CPU data (positions, constraints) if (this.strategy) {
const clothData = this.generateClothData(config); this.strategy.dispose();
}
// 3. Upload to GPU
const buffers = this.createStorageBuffers(engine, clothData); this.strategy = gpuTier === 'webgpu'
? new ClothGpuStrategy()
// 4. Create Compute Shaders : new ClothCpuStrategy();
const pipelines = this.setupComputePipelines(engine, buffers);
this.strategy.init(scene, engine, config);
// 5. Setup Rendering (Mesh, Material, Camera) this.startParamUpdateLoop(scene, engine);
this.setupRenderMesh(scene, config, buffers.positions); }
// 6. Start the physics loop private startParamUpdateLoop(scene: any, engine: any): void {
this.startRenderLoop(engine, scene, config, buffers, pipelines); 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 { private getClothConfig(): ClothConfig {
const gridWidth = 100; const gridWidth = 100;
const gridHeight = 100; const gridHeight = 100;
@@ -162,239 +155,4 @@ export class ClothComponent {
particleInvMass: 1.0 / particleMass 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);
});
}
} }

View File

@@ -1,6 +1,3 @@
// --- SIMULATION CONFIGURATION ---
import {ComputeShader, StorageBuffer} from '@babylonjs/core';
export interface ClothConfig { export interface ClothConfig {
gridWidth: number; gridWidth: number;
gridHeight: number; gridHeight: number;
@@ -10,27 +7,10 @@ export interface ClothConfig {
particleInvMass: number; particleInvMass: number;
} }
// --- RAW CPU DATA ---
export interface ClothData { export interface ClothData {
positions: Float32Array; positions: Float32Array;
prevPositions: Float32Array; prevPositions: Float32Array;
velocities: Float32Array; velocities: Float32Array;
constraints: number[][]; // Array containing the 4 phases constraints: number[][];
params: Float32Array; 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;
}

View File

@@ -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;
}
}

View File

@@ -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);
});
}
}

View File

@@ -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);
});
}
}

View File

@@ -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;
}

View File

@@ -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);
}
`;

View File

@@ -1,8 +1,7 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component'; import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; 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 {FormsModule} from '@angular/forms';
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider'; 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'; 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 {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {UrlConstants} from '../../../constants/UrlConstants'; 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({ @Component({
selector: 'app-pendulum', selector: 'app-pendulum',
@@ -31,7 +33,6 @@ import {UrlConstants} from '../../../constants/UrlConstants';
}) })
class PendulumComponent { class PendulumComponent {
// --- CONFIGURATION ---
algoInformation: AlgorithmInformation = { algoInformation: AlgorithmInformation = {
title: 'PENDULUM.EXPLANATION.TITLE', title: 'PENDULUM.EXPLANATION.TITLE',
entries: [ entries: [
@@ -46,15 +47,9 @@ class PendulumComponent {
disclaimerListEntry: ['PENDULUM.EXPLANATION.DISCLAIMER_1', 'PENDULUM.EXPLANATION.DISCLAIMER_2', 'PENDULUM.EXPLANATION.DISCLAIMER_3', 'PENDULUM.EXPLANATION.DISCLAIMER_4'] disclaimerListEntry: ['PENDULUM.EXPLANATION.DISCLAIMER_1', 'PENDULUM.EXPLANATION.DISCLAIMER_2', 'PENDULUM.EXPLANATION.DISCLAIMER_3', 'PENDULUM.EXPLANATION.DISCLAIMER_4']
}; };
renderConfig: RenderConfig = { renderConfig: RenderConfig = {
mode: '2D', mode: '2D',
initialViewSize: 2, initialViewSize: 2
shaderLanguage: ShaderLanguage.WGSL,
vertexShader: PENDULUM_VERTEX_SHADER_WGSL,
fragmentShader: PENDULUM_FRAGMENT_SHADER_WGSL,
uniformNames: [],
uniformBufferNames: []
}; };
trailDecayOptions: Options = { trailDecayOptions: Options = {
@@ -107,7 +102,6 @@ class PendulumComponent {
hidePointerLabels: false hidePointerLabels: false
}; };
// Central management of physics parameters
readonly simParams = { readonly simParams = {
time: 0, time: 0,
dt: 0.015, dt: 0.015,
@@ -123,6 +117,7 @@ class PendulumComponent {
}; };
private currentSceneData: SceneEventData | null = null; private currentSceneData: SceneEventData | null = null;
private strategy: PendulumSimulationStrategy | null = null;
onSceneReady(event: SceneEventData) { onSceneReady(event: SceneEventData) {
this.currentSceneData = event; this.currentSceneData = event;
@@ -130,83 +125,34 @@ class PendulumComponent {
} }
private createSimulation() { private createSimulation() {
if (!this.currentSceneData){ if (!this.currentSceneData) {
return; return;
} }
const {engine, scene} = this.currentSceneData;
engine.resize();
const width = engine.getRenderWidth(); const {engine, scene, gpuTier} = this.currentSceneData;
const height = engine.getRenderHeight();
const totalPixels = width * height;
// --- 1. BUFFERS --- if (this.strategy) {
const pixelBuffer = new StorageBuffer(engine, totalPixels * 4); this.strategy.dispose();
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);
} }
//remove old observables if available this.strategy = gpuTier === 'webgpu'
scene.onBeforeRenderObservable.clear(); ? new PendulumGpuStrategy()
// --- 4. RENDER LOOP --- : new PendulumCpuStrategy();
scene.onBeforeRenderObservable.add(() => {
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; this.simParams.time += this.simParams.dt;
const currentWidth = engine.getRenderWidth(); if (this.strategy) {
const currentHeight = engine.getRenderHeight(); this.strategy.updateParams({...this.simParams});
}
// 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
this.resetImpulses(); 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) { pushPendulum(m1: boolean) {
if (m1) if (m1) {
{
this.simParams.impulseM1 = IMPULSE_M1; this.simParams.impulseM1 = IMPULSE_M1;
return; return;
} }
@@ -235,6 +180,4 @@ class PendulumComponent {
} }
} }
export default PendulumComponent;
export default PendulumComponent

View File

@@ -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;
}

View File

@@ -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;
});
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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<GpuTier | null>(null);
async detect(): Promise<GpuTier> {
if (this.cachedTier) {
return this.cachedTier;
}
const result = await this.probe();
this.cachedTier = result;
this.tier.set(result);
return result;
}
private async probe(): Promise<GpuTier> {
if (await this.isWebGpuAvailable()) {
return 'webgpu';
}
if (this.isWebGlAvailable()) {
return 'webgl';
}
return 'none';
}
private async isWebGpuAvailable(): Promise<boolean> {
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;
}
}
}

View File

@@ -1,7 +1,8 @@
import {AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, NgZone, OnDestroy, Output, ViewChild} from '@angular/core'; import {AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, NgZone, OnDestroy, Output, ViewChild} from '@angular/core';
import {MatSnackBar} from '@angular/material/snack-bar'; import {MatSnackBar} from '@angular/material/snack-bar';
import {TranslateService} from '@ngx-translate/core'; 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 { export interface RenderConfig {
mode: '2D' | '3D'; mode: '2D' | '3D';
@@ -17,7 +18,8 @@ export type RenderCallback = (material: ShaderMaterial, camera: Camera, canvas:
export interface SceneEventData { export interface SceneEventData {
scene: Scene; scene: Scene;
engine: WebGPUEngine; engine: WebGPUEngine | Engine;
gpuTier: GpuTier;
} }
@Component({ @Component({
@@ -30,6 +32,7 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
readonly ngZone = inject(NgZone); readonly ngZone = inject(NgZone);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly gpuCapability = inject(GpuCapabilityService);
@ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>;
@@ -38,11 +41,13 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
@Output() sceneReady = new EventEmitter<SceneEventData>(); @Output() sceneReady = new EventEmitter<SceneEventData>();
@Output() sceneResized = new EventEmitter<SceneEventData>(); @Output() sceneResized = new EventEmitter<SceneEventData>();
@Output() engineUnavailable = new EventEmitter<{ reason: string }>();
private engine!: WebGPUEngine; private engine!: WebGPUEngine | Engine;
private scene!: Scene; private scene!: Scene;
private shaderMaterial!: ShaderMaterial; private shaderMaterial!: ShaderMaterial;
private camera!: Camera; private camera!: Camera;
private gpuTier: GpuTier = 'none';
//Listener //Listener
private readonly resizeHandler = () => this.handleResize(); private readonly resizeHandler = () => this.handleResize();
@@ -63,9 +68,44 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
private async initBabylon(): Promise<void> { private async initBabylon(): Promise<void> {
const canvas = this.canvasRef.nativeElement; const canvas = this.canvasRef.nativeElement;
const tier = await this.gpuCapability.detect();
this.gpuTier = tier;
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<void> {
const tmpEngine = new WebGPUEngine(canvas); const tmpEngine = new WebGPUEngine(canvas);
await tmpEngine.initAsync().then(() => {
try {
await tmpEngine.initAsync();
this.engine = tmpEngine; 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.scene = new Scene(this.engine);
this.setupCamera(canvas); this.setupCamera(canvas);
this.addListener(canvas); this.addListener(canvas);
@@ -73,16 +113,15 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
this.createFullScreenRect(); this.createFullScreenRect();
this.sceneReady.emit({ this.sceneReady.emit({
scene: this.scene, scene: this.scene,
engine: this.engine engine: this.engine,
gpuTier: this.gpuTier
}); });
this.addRenderLoop(canvas); this.addRenderLoop(canvas);
}
}) private showSnackBar(translationKey: string): void {
.catch(() => { const message = this.translate.instant(translationKey);
const message = this.translate.instant('WEBGPU.NOT_SUPPORTED'); this.snackBar.open(message, 'OK', {duration: 8000, horizontalPosition: 'center', verticalPosition: 'top'});
this.snackBar.open(message, 'OK', { duration: 8000, horizontalPosition: "center", verticalPosition: "top" });
this.engine = null!;
});
} }
private addListener(canvas: HTMLCanvasElement) { private addListener(canvas: HTMLCanvasElement) {
@@ -198,7 +237,8 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
this.sceneResized?.emit({ this.sceneResized?.emit({
scene: this.scene, scene: this.scene,
engine: this.engine engine: this.engine,
gpuTier: this.gpuTier
}); });
} }
} }

View File

@@ -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." "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": { "GPU": {
"NOT_SUPPORTED": "WebGPU konnte nicht gestartet werden. Bitte prüfe, ob dein Browser WebGPU unterstützt." "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": { "ALGORITHM": {
"TITLE": "Algorithmen", "TITLE": "Algorithmen",

View File

@@ -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." "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": { "GPU": {
"NOT_SUPPORTED": "WebGPU could not be started. Please check if your browser supports WebGPU." "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": { "ALGORITHM": {
"TITLE": "Algorithms", "TITLE": "Algorithms",