Add cloth simulation page with WGSL shaders

Introduce a new cloth simulation feature: adds ClothComponent (TS/HTML/SCSS) and WGSL compute/vertex/fragment shaders implementing an XPBD-based cloth sim. Wire up routing and RouterConstants, add the cloth entry to the algorithms list, and add English/German i18n strings. Also include small refactors/renames for algorithm-category and algorithms.service imports and update BabylonCanvas to tolerate optional shader configuration and avoid null access during setup.
This commit is contained in:
2026-02-23 11:02:54 +01:00
parent 885e609082
commit 954211b3cf
12 changed files with 461 additions and 11 deletions

View File

@@ -14,6 +14,7 @@ export const routes: Routes = [
{ path: RouterConstants.LABYRINTH.PATH, component: RouterConstants.LABYRINTH.COMPONENT}, { path: RouterConstants.LABYRINTH.PATH, component: RouterConstants.LABYRINTH.COMPONENT},
{ path: RouterConstants.FRACTAL.PATH, component: RouterConstants.FRACTAL.COMPONENT}, { path: RouterConstants.FRACTAL.PATH, component: RouterConstants.FRACTAL.COMPONENT},
{ path: RouterConstants.FRACTAL3d.PATH, component: RouterConstants.FRACTAL3d.COMPONENT}, { path: RouterConstants.FRACTAL3d.PATH, component: RouterConstants.FRACTAL3d.COMPONENT},
{ path: RouterConstants.PENDULUM.PATH, component: RouterConstants.PENDULUM.COMPONENT} { path: RouterConstants.PENDULUM.PATH, component: RouterConstants.PENDULUM.COMPONENT},
{ path: RouterConstants.CLOTH.PATH, component: RouterConstants.CLOTH.COMPONENT}
]; ];

View File

@@ -9,6 +9,7 @@ import {LabyrinthComponent} from '../pages/algorithms/pathfinding/labyrinth/laby
import {FractalComponent} from '../pages/algorithms/fractal/fractal.component'; import {FractalComponent} from '../pages/algorithms/fractal/fractal.component';
import {Fractal3dComponent} from '../pages/algorithms/fractal3d/fractal3d.component'; import {Fractal3dComponent} from '../pages/algorithms/fractal3d/fractal3d.component';
import PendulumComponent from '../pages/algorithms/pendulum/pendulum.component'; import PendulumComponent from '../pages/algorithms/pendulum/pendulum.component';
import {ClothComponent} from '../pages/algorithms/cloth/cloth.component';
export class RouterConstants { export class RouterConstants {
@@ -72,6 +73,12 @@ export class RouterConstants {
COMPONENT: PendulumComponent COMPONENT: PendulumComponent
}; };
static readonly CLOTH = {
PATH: 'algorithms/cloth',
LINK: '/algorithms/cloth',
COMPONENT: ClothComponent
};
static readonly IMPRINT = { static readonly IMPRINT = {
PATH: 'imprint', PATH: 'imprint',
LINK: '/imprint', LINK: '/imprint',

View File

@@ -1,6 +1,6 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { AlgorithmsService } from './service/algorithms.service'; import { AlgorithmsService } from './algorithms.service';
import { AlgorithmCategory } from './models/algorithm-category'; import { AlgorithmCategory } from './algorithm-category';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { AlgorithmCategory } from '../models/algorithm-category'; import { AlgorithmCategory } from './algorithm-category';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import {RouterConstants} from '../../../constants/RouterConstants'; import {RouterConstants} from '../../constants/RouterConstants';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -50,6 +50,12 @@ export class AlgorithmsService {
title: 'ALGORITHM.PENDULUM.TITLE', title: 'ALGORITHM.PENDULUM.TITLE',
description: 'ALGORITHM.PENDULUM.DESCRIPTION', description: 'ALGORITHM.PENDULUM.DESCRIPTION',
routerLink: RouterConstants.PENDULUM.LINK routerLink: RouterConstants.PENDULUM.LINK
},
{
id: 'cloth',
title: 'ALGORITHM.CLOTH.TITLE',
description: 'ALGORITHM.CLOTH.DESCRIPTION',
routerLink: RouterConstants.CLOTH.LINK
} }
]; ];

View File

@@ -0,0 +1,12 @@
<mat-card class="algo-container">
<mat-card-header>
<mat-card-title>{{ 'CLOTH.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-babylon-canvas
[config]="renderConfig"
(sceneReady)="onSceneReady($event)"
(sceneResized)="onSceneReady($event)"
/>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,207 @@
import { Component } from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/rendering/canvas/babylon-canvas.component';
import { ComputeShader, StorageBuffer, MeshBuilder, ShaderMaterial, ShaderLanguage } 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';
@Component({
selector: 'app-cloth.component',
imports: [
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
BabylonCanvas
],
templateUrl: './cloth.component.html',
styleUrl: './cloth.component.scss',
})
export class ClothComponent {
private currentSceneData: SceneEventData | null = null;
renderConfig: RenderConfig = {
mode: '3D',
initialViewSize: 20,
shaderLanguage: ShaderLanguage.WGSL
};
onSceneReady(event: SceneEventData) {
this.currentSceneData = event;
this.createSimulation();
}
private createSimulation() {
if (!this.currentSceneData){
return;
}
const {engine, scene} = this.currentSceneData;
// --- 1. CONFIGURE CLOTH GRID ---
const gridWidth = 50; // 50x50 = 2500 Vertices (Increase this later!)
const gridHeight = 50;
const numVertices = gridWidth * gridHeight;
const spacing = 0.1; // Distance between points
// Calculate approximate constraints (horizontal + vertical edges)
const numConstraints = (gridWidth - 1) * gridHeight + gridWidth * (gridHeight - 1);
// --- 2. INITIALIZE CPU ARRAYS (Strict vec4<f32> alignment) ---
const positionsData = new Float32Array(numVertices * 4);
const prevPositionsData = new Float32Array(numVertices * 4);
const velocitiesData = new Float32Array(numVertices * 4);
const constraintsData = new Float32Array(numConstraints * 4);
// Fill Initial Positions
for (let y = 0; y < gridHeight; y++) {
for (let x = 0; x < gridWidth; x++) {
const idx = (y * gridWidth + x) * 4;
// Center the cloth around X=0, let it hang down in Y
positionsData[idx + 0] = (x - gridWidth / 2) * spacing; // X
positionsData[idx + 1] = 5.0 - (y * spacing); // Y (Start at height 5)
positionsData[idx + 2] = 0.0; // Z
// Inverse Mass (w-component): Pin the top row!
// If y == 0, mass is 0.0 (pinned). Otherwise 1.0 (moves freely)
positionsData[idx + 3] = (y === 0) ? 0.0 : 1.0;
// PrevPositions start identical
prevPositionsData[idx + 0] = positionsData[idx + 0];
prevPositionsData[idx + 1] = positionsData[idx + 1];
prevPositionsData[idx + 2] = positionsData[idx + 2];
prevPositionsData[idx + 3] = positionsData[idx + 3];
}
}
// Fill Constraints (Simple Grid: connect right and connect down)
let cIdx = 0;
for (let y = 0; y < gridHeight; y++) {
for (let x = 0; x < gridWidth; x++) {
const indexA = y * gridWidth + x;
// Connect to right neighbor
if (x < gridWidth - 1) {
constraintsData[cIdx * 4 + 0] = indexA; // Vertex A
constraintsData[cIdx * 4 + 1] = indexA + 1; // Vertex B
constraintsData[cIdx * 4 + 2] = spacing; // Rest length
constraintsData[cIdx * 4 + 3] = 1.0; // Active flag
cIdx++;
}
// Connect to bottom neighbor
if (y < gridHeight - 1) {
constraintsData[cIdx * 4 + 0] = indexA; // Vertex A
constraintsData[cIdx * 4 + 1] = indexA + gridWidth; // Vertex B
constraintsData[cIdx * 4 + 2] = spacing; // Rest length
constraintsData[cIdx * 4 + 3] = 1.0; // Active flag
cIdx++;
}
}
}
// Parameters Data
const paramsData = new Float32Array(8); // Matches the WGSL struct (dt, gravity, etc.)
// --- 3. CREATE GPU STORAGE BUFFERS ---
const positionsBuffer = new StorageBuffer(engine, positionsData.byteLength);
positionsBuffer.update(positionsData);
const prevPositionsBuffer = new StorageBuffer(engine, prevPositionsData.byteLength);
prevPositionsBuffer.update(prevPositionsData);
const velocitiesBuffer = new StorageBuffer(engine, velocitiesData.byteLength);
// Automatically initialized to 0 by WebGPU, no update needed initially
const constraintsBuffer = new StorageBuffer(engine, constraintsData.byteLength);
constraintsBuffer.update(constraintsData);
const paramsBuffer = new StorageBuffer(engine, paramsData.byteLength);
// --- 4. SETUP COMPUTE SHADERS ---
const csIntegrate = new ComputeShader("integrate", engine, { computeSource: CLOTH_INTEGRATE_COMPUTE_WGSL }, {
bindingsMapping: {
"p": { group: 0, binding: 0 },
"positions": { group: 0, binding: 1 },
"prev_positions": { group: 0, binding: 2 },
"velocities": { group: 0, binding: 3 }
}
});
csIntegrate.setStorageBuffer("p", paramsBuffer);
csIntegrate.setStorageBuffer("positions", positionsBuffer);
csIntegrate.setStorageBuffer("prev_positions", prevPositionsBuffer);
csIntegrate.setStorageBuffer("velocities", velocitiesBuffer);
// --- SETUP: csSolve (XPBD Constraints) ---
const csSolve = new ComputeShader("solve", engine, { computeSource: CLOTH_SOLVE_COMPUTE_WGSL }, {
bindingsMapping: {
"p": { group: 0, binding: 0 },
"positions": { group: 0, binding: 1 },
"constraints": { group: 0, binding: 2 }
}
});
csSolve.setStorageBuffer("p", paramsBuffer);
csSolve.setStorageBuffer("positions", positionsBuffer);
csSolve.setStorageBuffer("constraints", constraintsBuffer);
// --- SETUP: csVelocity (Update Velocities) ---
const csVelocity = new ComputeShader("velocity", engine, { computeSource: CLOTH_VELOCITY_COMPUTE_WGSL }, {
bindingsMapping: {
"p": { group: 0, binding: 0 },
"positions": { group: 0, binding: 1 },
"prev_positions": { group: 0, binding: 2 },
"velocities": { group: 0, binding: 3 }
}
});
csVelocity.setStorageBuffer("p", paramsBuffer);
csVelocity.setStorageBuffer("positions", positionsBuffer);
csVelocity.setStorageBuffer("prev_positions", prevPositionsBuffer);
csVelocity.setStorageBuffer("velocities", velocitiesBuffer);
// --- 5. SETUP RENDER MESH ---
// We create a ground mesh that matches our grid size, but we will OVERWRITE its vertices in the shader.
const clothMesh = MeshBuilder.CreateGround("cloth", { width: 10, height: 10, subdivisions: 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.setStorageBuffer("positions", positionsBuffer);
clothMesh.material = clothMaterial;
clothMaterial.setStorageBuffer("positions", positionsBuffer);
clothMesh.material = clothMaterial;
// --- 6. RENDER LOOP ---
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
// 1. Update Parameters (just an example, bind your simParams here)
paramsData[0] = 0.016; // dt
paramsData[1] = -9.81; // gravity
paramsData[2] = 0.001; // compliance (stiffness)
paramsData[3] = numVertices;
paramsData[4] = numConstraints;
paramsBuffer.update(paramsData);
// 2. Dispatch Compute Shaders in sequence!
const dispatchXVertices = Math.ceil(numVertices / 64);
const dispatchXConstraints = Math.ceil(numConstraints / 64);
csIntegrate.dispatch(dispatchXVertices, 1, 1);
// For XPBD stability, you often run the solver multiple times (substeps)
for (let i = 0; i < 5; i++) {
csSolve.dispatch(dispatchXConstraints, 1, 1);
}
csVelocity.dispatch(dispatchXVertices, 1, 1);
});
}
}

View File

@@ -0,0 +1,195 @@
// --- SHARED DATA STRUCTURES ---
export const CLOTH_SHARED_STRUCTS = `
struct Params {
dt: f32, // Time step per substep
gravity_y: f32, // Gravity (e.g. -9.81)
compliance: f32, // Inverse stiffness (0.0 = completely rigid)
numVertices: f32, // Total number of vertices
numConstraints: f32, // Total number of springs
pad1: f32, // Padding
pad2: f32, // Padding
pad3: f32 // Padding (8 * f32 = 32 bytes)
};
`;
// ==========================================
// VERTEX SHADER
// ==========================================
export const CLOTH_VERTEX_SHADER_WGSL = `
attribute uv : vec2<f32>;
// Storage Buffer
var<storage, read> positions : array<vec4<f32>>;
// Babylon Preprocessor-Magie
uniform viewProjection : mat4x4<f32>;
varying vUV : vec2<f32>;
@vertex
fn main(input : VertexInputs) -> FragmentInputs {
var output : FragmentInputs;
let worldPos = positions[input.vertexIndex].xyz;
output.position = uniforms.viewProjection * vec4<f32>(worldPos, 1.0);
output.vUV = input.uv;
return output;
}
`;
// ==========================================
// FRAGMENT SHADER (Bleibt exakt gleich)
// ==========================================
export const CLOTH_FRAGMENT_SHADER_WGSL = `
varying vUV : vec2<f32>;
@fragment
fn main(input: FragmentInputs) -> FragmentOutputs {
var output: FragmentOutputs;
let color = vec3<f32>(input.vUV.x * 0.8, input.vUV.y * 0.8, 0.9);
output.color = vec4<f32>(color, 1.0);
return output;
}
`;
// =====================================================================
// PASS 1: INTEGRATION (Apply Forces & Predict Positions)
// =====================================================================
export const CLOTH_INTEGRATE_COMPUTE_WGSL = CLOTH_SHARED_STRUCTS + `
@group(0) @binding(0) var<storage, read> p : Params;
@group(0) @binding(1) var<storage, read_write> positions : array<vec4<f32>>;
@group(0) @binding(2) var<storage, read_write> prev_positions : array<vec4<f32>>;
@group(0) @binding(3) var<storage, read_write> velocities : array<vec4<f32>>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
let idx = global_id.x;
if (f32(idx) >= p.numVertices) { return; }
var pos = positions[idx];
var vel = velocities[idx];
let invMass = pos.w; // w stores inverse mass (0.0 = pinned/static)
// Only move if it is not pinned
if (invMass > 0.0) {
// 1. Apply Gravity: v = v + g * dt
vel.y = vel.y + (p.gravity_y * p.dt);
// 2. Save current position for later velocity calculation
prev_positions[idx] = pos;
// 3. Predict new position: p = p + v * dt
pos.x = pos.x + vel.x * p.dt;
pos.y = pos.y + vel.y * p.dt;
pos.z = pos.z + vel.z * p.dt;
positions[idx] = pos;
velocities[idx] = vel;
}
}
`;
// =====================================================================
// PASS 2: SOLVE CONSTRAINTS (The core of XPBD)
// =====================================================================
export const CLOTH_SOLVE_COMPUTE_WGSL = CLOTH_SHARED_STRUCTS + `
@group(0) @binding(0) var<storage, read> p : Params;
@group(0) @binding(1) var<storage, read_write> positions : array<vec4<f32>>;
@group(0) @binding(2) var<storage, read_write> constraints : array<vec4<f32>>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
let idx = global_id.x;
if (f32(idx) >= p.numConstraints) { return; }
let constraint = constraints[idx];
let isActive = constraint.w; // 1.0 = Active, 0.0 = Cut/Broken
// If the cloth is cut here, skip this constraint!
if (isActive < 0.5) { return; }
let idA = u32(constraint.x);
let idB = u32(constraint.y);
let restLength = constraint.z;
var pA = positions[idA];
var pB = positions[idB];
let wA = pA.w; // Inverse mass A
let wB = pB.w; // Inverse mass B
let wSum = wA + wB;
// If both points are pinned, do nothing
if (wSum <= 0.0) { return; }
let dir = pA.xyz - pB.xyz;
let dist = length(dir);
// Prevent division by zero
if (dist < 0.0001) { return; }
// XPBD Calculation (Extended Position-Based Dynamics)
let n = dir / dist;
let C = dist - restLength; // Constraint violation (how much it stretched)
// Calculate the correction factor (alpha represents the XPBD compliance)
let alpha = p.compliance / (p.dt * p.dt);
let lambda = -C / (wSum + alpha);
// Apply position corrections directly to the points
let corrA = n * (lambda * wA);
let corrB = n * (-lambda * wB);
// NOTE: In a multi-threaded GPU environment without "Graph Coloring",
// writing directly to positions like this can cause minor race conditions
// (flickering). We will handle Graph Coloring in the TypeScript setup!
if (wA > 0.0) {
positions[idA].x = positions[idA].x + corrA.x;
positions[idA].y = positions[idA].y + corrA.y;
positions[idA].z = positions[idA].z + corrA.z;
}
if (wB > 0.0) {
positions[idB].x = positions[idB].x + corrB.x;
positions[idB].y = positions[idB].y + corrB.y;
positions[idB].z = positions[idB].z + corrB.z;
}
}
`;
// =====================================================================
// PASS 3: VELOCITY UPDATE (Derive velocity from position changes)
// =====================================================================
export const CLOTH_VELOCITY_COMPUTE_WGSL = CLOTH_SHARED_STRUCTS + `
@group(0) @binding(0) var<storage, read> p : Params;
@group(0) @binding(1) var<storage, read_write> positions : array<vec4<f32>>;
@group(0) @binding(2) var<storage, read_write> prev_positions : array<vec4<f32>>;
@group(0) @binding(3) var<storage, read_write> velocities : array<vec4<f32>>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
let idx = global_id.x;
if (f32(idx) >= p.numVertices) { return; }
let pos = positions[idx];
let prev = prev_positions[idx];
let invMass = pos.w;
if (invMass > 0.0) {
var vel = velocities[idx];
// v = (p - p_prev) / dt
vel.x = (pos.x - prev.x) / p.dt;
vel.y = (pos.y - prev.y) / p.dt;
vel.z = (pos.z - prev.z) / p.dt;
// Optional: Add simple damping here
// vel = vel * 0.99;
velocities[idx] = vel;
}
}
`;

View File

@@ -5,9 +5,9 @@ export interface RenderConfig {
mode: '2D' | '3D'; mode: '2D' | '3D';
shaderLanguage?: number; //0 GLSL, 1 WGSL shaderLanguage?: number; //0 GLSL, 1 WGSL
initialViewSize: number; initialViewSize: number;
vertexShader: string; vertexShader?: string;
fragmentShader: string; fragmentShader?: string;
uniformNames: string[]; uniformNames?: string[];
uniformBufferNames?: string[]; uniformBufferNames?: string[];
} }
@@ -120,8 +120,10 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
} }
private createFullScreenRect() { private createFullScreenRect() {
if (!this.config.vertexShader || !this.config.fragmentShader) {
return;
}
const plane = MeshBuilder.CreatePlane("plane", {size: 100}, this.scene); const plane = MeshBuilder.CreatePlane("plane", {size: 100}, this.scene);
if (this.config.mode === '3D') { if (this.config.mode === '3D') {
plane.parent = this.camera; plane.parent = this.camera;
plane.position.z = 1; plane.position.z = 1;
@@ -134,6 +136,10 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
} }
private createShaderMaterial() { private createShaderMaterial() {
if (!this.config.vertexShader || !this.config.fragmentShader || !this.config.uniformNames) {
return;
}
this.shaderMaterial = new ShaderMaterial( this.shaderMaterial = new ShaderMaterial(
"shaderMaterial", "shaderMaterial",
this.scene, this.scene,
@@ -161,8 +167,10 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
} }
// default uniforms which maybe each scene has // default uniforms which maybe each scene has
this.shaderMaterial.setVector2("resolution", new Vector2(canvas.width, canvas.height)); if (this.shaderMaterial) {
this.shaderMaterial.setVector3("cameraPosition", this.camera.position); this.shaderMaterial.setVector2("resolution", new Vector2(canvas.width, canvas.height));
this.shaderMaterial.setVector3("cameraPosition", this.camera.position);
}
this.scene.render(); this.scene.render();
}); });

View File

@@ -471,6 +471,9 @@
"DISCLAIMER_BOTTOM": "HINWEIS: Wenn zuviele Impulse in das System gegeben werden, wird die Simulation instabil. Dann hängt das Pendel nur noch runter und es muss neu gestartet werden." "DISCLAIMER_BOTTOM": "HINWEIS: Wenn zuviele Impulse in das System gegeben werden, wird die Simulation instabil. Dann hängt das Pendel nur noch runter und es muss neu gestartet werden."
} }
}, },
"CLOTH": {
"TITLE": "Stoff-Simulation"
},
"ALGORITHM": { "ALGORITHM": {
"TITLE": "Algorithmen", "TITLE": "Algorithmen",
"PATHFINDING": { "PATHFINDING": {
@@ -501,6 +504,10 @@
"TITLE": "Doppel-Pendel", "TITLE": "Doppel-Pendel",
"DESCRIPTION": "Visualisierung einer chaotischen Doppel-Pendel-Simulation mit WebGPU." "DESCRIPTION": "Visualisierung einer chaotischen Doppel-Pendel-Simulation mit WebGPU."
}, },
"CLOTH": {
"TITLE": "Stoff-Simulation",
"DESCRIPTION": "Simulation on Stoff mit WebGPU."
}
"NOTE": "HINWEIS", "NOTE": "HINWEIS",
"GRID_HEIGHT": "Höhe", "GRID_HEIGHT": "Höhe",
"GRID_WIDTH": "Beite" "GRID_WIDTH": "Beite"

View File

@@ -470,6 +470,9 @@
"DISCLAIMER_BOTTOM": "NOTE: If too many impulses are fed into the system, the simulation becomes unstable. The pendulum will then just hang down and the simulation will have to be restarted." "DISCLAIMER_BOTTOM": "NOTE: If too many impulses are fed into the system, the simulation becomes unstable. The pendulum will then just hang down and the simulation will have to be restarted."
} }
}, },
"CLOTH": {
"TITLE": "Cloth simulation"
},
"ALGORITHM": { "ALGORITHM": {
"TITLE": "Algorithms", "TITLE": "Algorithms",
"PATHFINDING": { "PATHFINDING": {
@@ -500,6 +503,10 @@
"TITLE": "Double pendulum", "TITLE": "Double pendulum",
"DESCRIPTION": "Visualisation of a chaotic double pendulum simulation with WebGPU." "DESCRIPTION": "Visualisation of a chaotic double pendulum simulation with WebGPU."
}, },
"CLOTH": {
"TITLE": "Cloth simulation",
"DESCRIPTION": "Simulation of cloth with WebGPU."
},
"NOTE": "Note", "NOTE": "Note",
"GRID_HEIGHT": "Height", "GRID_HEIGHT": "Height",
"GRID_WIDTH": "Width" "GRID_WIDTH": "Width"