From 954211b3cf852a5e3daa95a044f404908806e973 Mon Sep 17 00:00:00 2001 From: Lobo Date: Mon, 23 Feb 2026 11:02:54 +0100 Subject: [PATCH] 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. --- src/app/app.routes.ts | 3 +- src/app/constants/RouterConstants.ts | 7 + .../{models => }/algorithm-category.ts | 0 .../pages/algorithms/algorithms.component.ts | 4 +- .../{service => }/algorithms.service.ts | 10 +- .../algorithms/cloth/cloth.component.html | 12 + .../algorithms/cloth/cloth.component.scss | 0 .../pages/algorithms/cloth/cloth.component.ts | 207 ++++++++++++++++++ .../pages/algorithms/cloth/cloth.shader.ts | 195 +++++++++++++++++ .../canvas/babylon-canvas.component.ts | 20 +- src/assets/i18n/de.json | 7 + src/assets/i18n/en.json | 7 + 12 files changed, 461 insertions(+), 11 deletions(-) rename src/app/pages/algorithms/{models => }/algorithm-category.ts (100%) rename src/app/pages/algorithms/{service => }/algorithms.service.ts (84%) create mode 100644 src/app/pages/algorithms/cloth/cloth.component.html create mode 100644 src/app/pages/algorithms/cloth/cloth.component.scss create mode 100644 src/app/pages/algorithms/cloth/cloth.component.ts create mode 100644 src/app/pages/algorithms/cloth/cloth.shader.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index d079fbb..8e69cac 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -14,6 +14,7 @@ export const routes: Routes = [ { path: RouterConstants.LABYRINTH.PATH, component: RouterConstants.LABYRINTH.COMPONENT}, { path: RouterConstants.FRACTAL.PATH, component: RouterConstants.FRACTAL.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} ]; diff --git a/src/app/constants/RouterConstants.ts b/src/app/constants/RouterConstants.ts index ac1efab..1ab61ea 100644 --- a/src/app/constants/RouterConstants.ts +++ b/src/app/constants/RouterConstants.ts @@ -9,6 +9,7 @@ import {LabyrinthComponent} from '../pages/algorithms/pathfinding/labyrinth/laby import {FractalComponent} from '../pages/algorithms/fractal/fractal.component'; import {Fractal3dComponent} from '../pages/algorithms/fractal3d/fractal3d.component'; import PendulumComponent from '../pages/algorithms/pendulum/pendulum.component'; +import {ClothComponent} from '../pages/algorithms/cloth/cloth.component'; export class RouterConstants { @@ -72,6 +73,12 @@ export class RouterConstants { COMPONENT: PendulumComponent }; + static readonly CLOTH = { + PATH: 'algorithms/cloth', + LINK: '/algorithms/cloth', + COMPONENT: ClothComponent + }; + static readonly IMPRINT = { PATH: 'imprint', LINK: '/imprint', diff --git a/src/app/pages/algorithms/models/algorithm-category.ts b/src/app/pages/algorithms/algorithm-category.ts similarity index 100% rename from src/app/pages/algorithms/models/algorithm-category.ts rename to src/app/pages/algorithms/algorithm-category.ts diff --git a/src/app/pages/algorithms/algorithms.component.ts b/src/app/pages/algorithms/algorithms.component.ts index 8639131..722dd97 100644 --- a/src/app/pages/algorithms/algorithms.component.ts +++ b/src/app/pages/algorithms/algorithms.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit, inject } from '@angular/core'; -import { AlgorithmsService } from './service/algorithms.service'; -import { AlgorithmCategory } from './models/algorithm-category'; +import { AlgorithmsService } from './algorithms.service'; +import { AlgorithmCategory } from './algorithm-category'; import { Observable } from 'rxjs'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; diff --git a/src/app/pages/algorithms/service/algorithms.service.ts b/src/app/pages/algorithms/algorithms.service.ts similarity index 84% rename from src/app/pages/algorithms/service/algorithms.service.ts rename to src/app/pages/algorithms/algorithms.service.ts index c56b771..0db4f56 100644 --- a/src/app/pages/algorithms/service/algorithms.service.ts +++ b/src/app/pages/algorithms/algorithms.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; -import { AlgorithmCategory } from '../models/algorithm-category'; +import { AlgorithmCategory } from './algorithm-category'; import { Observable, of } from 'rxjs'; -import {RouterConstants} from '../../../constants/RouterConstants'; +import {RouterConstants} from '../../constants/RouterConstants'; @Injectable({ providedIn: 'root' @@ -50,6 +50,12 @@ export class AlgorithmsService { title: 'ALGORITHM.PENDULUM.TITLE', description: 'ALGORITHM.PENDULUM.DESCRIPTION', routerLink: RouterConstants.PENDULUM.LINK + }, + { + id: 'cloth', + title: 'ALGORITHM.CLOTH.TITLE', + description: 'ALGORITHM.CLOTH.DESCRIPTION', + routerLink: RouterConstants.CLOTH.LINK } ]; diff --git a/src/app/pages/algorithms/cloth/cloth.component.html b/src/app/pages/algorithms/cloth/cloth.component.html new file mode 100644 index 0000000..f98a204 --- /dev/null +++ b/src/app/pages/algorithms/cloth/cloth.component.html @@ -0,0 +1,12 @@ + + + {{ 'CLOTH.TITLE' | translate }} + + + + + diff --git a/src/app/pages/algorithms/cloth/cloth.component.scss b/src/app/pages/algorithms/cloth/cloth.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/algorithms/cloth/cloth.component.ts b/src/app/pages/algorithms/cloth/cloth.component.ts new file mode 100644 index 0000000..7dc5086 --- /dev/null +++ b/src/app/pages/algorithms/cloth/cloth.component.ts @@ -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 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); + }); + } +} diff --git a/src/app/pages/algorithms/cloth/cloth.shader.ts b/src/app/pages/algorithms/cloth/cloth.shader.ts new file mode 100644 index 0000000..62068bb --- /dev/null +++ b/src/app/pages/algorithms/cloth/cloth.shader.ts @@ -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; + + // Storage Buffer + var positions : array>; + + // Babylon Preprocessor-Magie + uniform viewProjection : mat4x4; + varying vUV : vec2; + + @vertex + fn main(input : VertexInputs) -> FragmentInputs { + var output : FragmentInputs; + + let worldPos = positions[input.vertexIndex].xyz; + + output.position = uniforms.viewProjection * vec4(worldPos, 1.0); + output.vUV = input.uv; + + return output; + } +`; + +// ========================================== +// FRAGMENT SHADER (Bleibt exakt gleich) +// ========================================== +export const CLOTH_FRAGMENT_SHADER_WGSL = ` + varying vUV : vec2; + + @fragment + fn main(input: FragmentInputs) -> FragmentOutputs { + var output: FragmentOutputs; + + let color = vec3(input.vUV.x * 0.8, input.vUV.y * 0.8, 0.9); + output.color = vec4(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 p : Params; + @group(0) @binding(1) var positions : array>; + @group(0) @binding(2) var prev_positions : array>; + @group(0) @binding(3) var velocities : array>; + + @compute @workgroup_size(64) + fn main(@builtin(global_invocation_id) global_id : vec3) { + 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 p : Params; + @group(0) @binding(1) var positions : array>; + @group(0) @binding(2) var constraints : array>; + + @compute @workgroup_size(64) + fn main(@builtin(global_invocation_id) global_id : vec3) { + 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 p : Params; + @group(0) @binding(1) var positions : array>; + @group(0) @binding(2) var prev_positions : array>; + @group(0) @binding(3) var velocities : array>; + + @compute @workgroup_size(64) + fn main(@builtin(global_invocation_id) global_id : vec3) { + 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; + } + } +`; diff --git a/src/app/shared/rendering/canvas/babylon-canvas.component.ts b/src/app/shared/rendering/canvas/babylon-canvas.component.ts index 975a187..8e61be0 100644 --- a/src/app/shared/rendering/canvas/babylon-canvas.component.ts +++ b/src/app/shared/rendering/canvas/babylon-canvas.component.ts @@ -5,9 +5,9 @@ export interface RenderConfig { mode: '2D' | '3D'; shaderLanguage?: number; //0 GLSL, 1 WGSL initialViewSize: number; - vertexShader: string; - fragmentShader: string; - uniformNames: string[]; + vertexShader?: string; + fragmentShader?: string; + uniformNames?: string[]; uniformBufferNames?: string[]; } @@ -120,8 +120,10 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy { } private createFullScreenRect() { + if (!this.config.vertexShader || !this.config.fragmentShader) { + return; + } const plane = MeshBuilder.CreatePlane("plane", {size: 100}, this.scene); - if (this.config.mode === '3D') { plane.parent = this.camera; plane.position.z = 1; @@ -134,6 +136,10 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy { } private createShaderMaterial() { + if (!this.config.vertexShader || !this.config.fragmentShader || !this.config.uniformNames) { + return; + } + this.shaderMaterial = new ShaderMaterial( "shaderMaterial", this.scene, @@ -161,8 +167,10 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy { } // default uniforms which maybe each scene has - this.shaderMaterial.setVector2("resolution", new Vector2(canvas.width, canvas.height)); - this.shaderMaterial.setVector3("cameraPosition", this.camera.position); + if (this.shaderMaterial) { + this.shaderMaterial.setVector2("resolution", new Vector2(canvas.width, canvas.height)); + this.shaderMaterial.setVector3("cameraPosition", this.camera.position); + } this.scene.render(); }); diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index 6f46219..dcd275b 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -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." } }, + "CLOTH": { + "TITLE": "Stoff-Simulation" + }, "ALGORITHM": { "TITLE": "Algorithmen", "PATHFINDING": { @@ -501,6 +504,10 @@ "TITLE": "Doppel-Pendel", "DESCRIPTION": "Visualisierung einer chaotischen Doppel-Pendel-Simulation mit WebGPU." }, + "CLOTH": { + "TITLE": "Stoff-Simulation", + "DESCRIPTION": "Simulation on Stoff mit WebGPU." + } "NOTE": "HINWEIS", "GRID_HEIGHT": "Höhe", "GRID_WIDTH": "Beite" diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index a5edf3d..186685b 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -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." } }, + "CLOTH": { + "TITLE": "Cloth simulation" + }, "ALGORITHM": { "TITLE": "Algorithms", "PATHFINDING": { @@ -500,6 +503,10 @@ "TITLE": "Double pendulum", "DESCRIPTION": "Visualisation of a chaotic double pendulum simulation with WebGPU." }, + "CLOTH": { + "TITLE": "Cloth simulation", + "DESCRIPTION": "Simulation of cloth with WebGPU." + }, "NOTE": "Note", "GRID_HEIGHT": "Height", "GRID_WIDTH": "Width"