diff --git a/src/app/pages/algorithms/cloth/cloth.component.html b/src/app/pages/algorithms/cloth/cloth.component.html index cbc11a7..5b9d42a 100644 --- a/src/app/pages/algorithms/cloth/cloth.component.html +++ b/src/app/pages/algorithms/cloth/cloth.component.html @@ -23,6 +23,7 @@ + ({constraints: 0, phases: 0}); + readonly metrics = computed(() => [ + {label: 'METRICS.FPS', value: this.fpsCounter.fps()}, + {label: 'METRICS.BACKEND', value: this.backend()}, + {label: 'METRICS.PARTICLES', value: this.clothConfig.numVertices}, + {label: 'METRICS.CONSTRAINTS', value: this.stats().constraints}, + {label: 'METRICS.PHASES', value: this.stats().phases}, + {label: 'METRICS.SUBSTEPS', value: CLOTH_SUBSTEPS} + ]); + public renderConfig: RenderConfig = { mode: '3D', initialViewSize: 20 @@ -110,7 +126,7 @@ export class ClothComponent { } const {engine, scene, gpuTier} = this.currentSceneData; - const config = this.getClothConfig(); + const config = this.clothConfig; if (this.strategy) { this.strategy.dispose(); @@ -120,13 +136,16 @@ export class ClothComponent { ? new ClothGpuStrategy() : new ClothCpuStrategy(); + this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU'); this.strategy.init(scene, engine, config); + this.stats.set(this.strategy.getStats()); this.startParamUpdateLoop(scene, engine); } private startParamUpdateLoop(scene: any, engine: any): void { scene.onAfterRenderObservable.clear(); scene.onAfterRenderObservable.add(() => { + this.fpsCounter.tick(); this.simulationTime += engine.getDeltaTime() / 1000.0; if (this.strategy) { diff --git a/src/app/pages/algorithms/cloth/cloth.model.ts b/src/app/pages/algorithms/cloth/cloth.model.ts index 8f26122..6c09f07 100644 --- a/src/app/pages/algorithms/cloth/cloth.model.ts +++ b/src/app/pages/algorithms/cloth/cloth.model.ts @@ -7,6 +7,21 @@ export interface ClothConfig { particleInvMass: number; } +export const CLOTH_SUBSTEPS = 15; + +export interface ClothStats { + constraints: number; + phases: number; +} + +/** Each constraint takes 4 numbers in its phase array. */ +export function clothStatsOf(constraintPhases: number[][]): ClothStats { + return { + constraints: constraintPhases.reduce((sum, phase) => sum + phase.length / 4, 0), + phases: constraintPhases.length + }; +} + export interface ClothData { positions: Float32Array; prevPositions: Float32Array; diff --git a/src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts b/src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts index 29d1b53..55c0530 100644 --- a/src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts +++ b/src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts @@ -1,5 +1,5 @@ import {ArcRotateCamera, Engine, GroundMesh, MeshBuilder, Scene, ShaderMaterial, VertexBuffer, WebGPUEngine} from '@babylonjs/core'; -import {ClothConfig, ClothData} from '../cloth.model'; +import {CLOTH_SUBSTEPS, ClothConfig, ClothData, ClothStats, clothStatsOf} 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'; @@ -13,6 +13,7 @@ export class ClothCpuStrategy implements ClothSimulationStrategy { private prevPositions!: Float32Array; private velocities!: Float32Array; private constraintPhases!: Float32Array[]; + private stats: ClothStats = {constraints: 0, phases: 0}; private physicsParams: ClothPhysicsParams = { dt: 0.016, @@ -36,11 +37,16 @@ export class ClothCpuStrategy implements ClothSimulationStrategy { this.prevPositions = clothData.prevPositions; this.velocities = clothData.velocities; this.constraintPhases = clothData.constraints.map(c => new Float32Array(c)); + this.stats = clothStatsOf(clothData.constraints); this.setupRenderMesh(scene, config); this.startRenderLoop(scene, config); } + getStats(): ClothStats { + return this.stats; + } + updateParams(params: ClothSimulationParams): void { const windX = params.isWindActive ? 5.0 : 0.0; const windZ = params.isWindActive ? 15.0 : 0.0; @@ -191,8 +197,6 @@ export class ClothCpuStrategy implements ClothSimulationStrategy { } private startRenderLoop(scene: Scene, config: ClothConfig): void { - const substeps = 15; - scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.add(() => { if (!this.clothMesh) { @@ -201,7 +205,7 @@ export class ClothCpuStrategy implements ClothSimulationStrategy { integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams); - for (let i = 0; i < substeps; i++) { + for (let i = 0; i < CLOTH_SUBSTEPS; i++) { for (const phase of this.constraintPhases) { solveConstraints(this.positions, phase, this.physicsParams); } diff --git a/src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts b/src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts index 822f9ca..6dec054 100644 --- a/src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts +++ b/src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts @@ -1,5 +1,5 @@ import {ArcRotateCamera, ComputeShader, Engine, GroundMesh, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core'; -import {ClothConfig, ClothData} from '../cloth.model'; +import {CLOTH_SUBSTEPS, ClothConfig, ClothData, ClothStats, clothStatsOf} from '../cloth.model'; import { CLOTH_FRAGMENT_SHADER_WGSL, CLOTH_INTEGRATE_COMPUTE_WGSL, @@ -32,6 +32,7 @@ export class ClothGpuStrategy implements ClothSimulationStrategy { private dispatchXConstraints: number[] = []; private dispatchXVertices = 0; private numVertices = 0; + private stats: ClothStats = {constraints: 0, phases: 0}; init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void { this.scene = scene; @@ -39,6 +40,7 @@ export class ClothGpuStrategy implements ClothSimulationStrategy { const gpuEngine = engine as WebGPUEngine; const clothData = this.generateClothData(config); + this.stats = clothStatsOf(clothData.constraints); this.buffers = this.createStorageBuffers(gpuEngine, clothData); this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers); this.setupRenderMesh(scene, config, this.buffers.positions); @@ -46,6 +48,10 @@ export class ClothGpuStrategy implements ClothSimulationStrategy { this.startRenderLoop(scene); } + getStats(): ClothStats { + return this.stats; + } + updateParams(params: ClothSimulationParams): void { if (!this.buffers) { return; @@ -251,8 +257,6 @@ export class ClothGpuStrategy implements ClothSimulationStrategy { } private startRenderLoop(scene: Scene): void { - const substeps = 15; - scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.add(() => { if (!this.buffers || !this.pipelines) { @@ -263,7 +267,7 @@ export class ClothGpuStrategy implements ClothSimulationStrategy { this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1); - for (let i = 0; i < substeps; i++) { + for (let i = 0; i < CLOTH_SUBSTEPS; i++) { for (let phase = 0; phase < this.pipelines.solvers.length; phase++) { this.pipelines.solvers[phase].dispatch(this.dispatchXConstraints[phase], 1, 1); } diff --git a/src/app/pages/algorithms/cloth/strategies/cloth-simulation.strategy.ts b/src/app/pages/algorithms/cloth/strategies/cloth-simulation.strategy.ts index 46d3d50..7aee7c2 100644 --- a/src/app/pages/algorithms/cloth/strategies/cloth-simulation.strategy.ts +++ b/src/app/pages/algorithms/cloth/strategies/cloth-simulation.strategy.ts @@ -1,5 +1,5 @@ import {Engine, GroundMesh, Scene, WebGPUEngine} from '@babylonjs/core'; -import {ClothConfig} from '../cloth.model'; +import {ClothConfig, ClothStats} from '../cloth.model'; export interface ClothSimulationParams { stiffness: number; @@ -13,5 +13,6 @@ export interface ClothSimulationStrategy { init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void; updateParams(params: ClothSimulationParams): void; getMesh(): GroundMesh | null; + getStats(): ClothStats; dispose(): void; } diff --git a/src/app/pages/algorithms/conway-gol/conway-gol.component.html b/src/app/pages/algorithms/conway-gol/conway-gol.component.html index 6614973..5cd6096 100644 --- a/src/app/pages/algorithms/conway-gol/conway-gol.component.html +++ b/src/app/pages/algorithms/conway-gol/conway-gol.component.html @@ -33,8 +33,8 @@ play_arrow {{ 'GOL.START' | translate }} } -

{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime() }} ms

+
{{ 'ALGORITHM.GRID_HEIGHT' | translate }} diff --git a/src/app/pages/algorithms/conway-gol/conway-gol.component.spec.ts b/src/app/pages/algorithms/conway-gol/conway-gol.component.spec.ts index 697bb61..c81edf0 100644 --- a/src/app/pages/algorithms/conway-gol/conway-gol.component.spec.ts +++ b/src/app/pages/algorithms/conway-gol/conway-gol.component.spec.ts @@ -65,4 +65,16 @@ describe('ConwayGolComponent', () => { expect(component['gridCols']()).toBeNull(); expect(component.readGrid[0].length).toBe(20); }); + + it('counts generations while running and restarts at 0 on generate', () => { + const run = component.startGame(); + component.pauseGame(); + + expect(component.generation()).toBeGreaterThanOrEqual(1); + + component.generate(component.currentScenario); + + expect(component.generation()).toBe(0); + return run; + }); }); diff --git a/src/app/pages/algorithms/conway-gol/conway-gol.component.ts b/src/app/pages/algorithms/conway-gol/conway-gol.component.ts index 7019023..f5aa4ef 100644 --- a/src/app/pages/algorithms/conway-gol/conway-gol.component.ts +++ b/src/app/pages/algorithms/conway-gol/conway-gol.component.ts @@ -1,4 +1,4 @@ -import {AfterViewInit, Component, inject, signal, ViewChild} from '@angular/core'; +import {AfterViewInit, Component, computed, inject, signal, ViewChild} from '@angular/core'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card"; import {TranslatePipe} from "@ngx-translate/core"; import {UrlConstants} from '../../../constants/UrlConstants'; @@ -12,6 +12,8 @@ import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TIME_PER_GENERATION, MAX_GRID_PX, MAX_GRID_SIZE, MAX_TIME_PER_GENERATION, MIN_GRID_SIZE, MIN_TIME_PER_GENERATION, Node, Scenario} from './conway-gol.models'; import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid'; import {ConwayGolService} from './service/conway-gol.service'; +import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; +import {FpsCounter} from '../../../shared/fps-counter'; @Component({ selector: 'app-conway-gol', @@ -29,7 +31,8 @@ import {ConwayGolService} from './service/conway-gol.service'; MatLabel, ReactiveFormsModule, FormsModule, - GenericGridComponent + GenericGridComponent, + MetricsComponent ], templateUrl: './conway-gol.component.html', }) @@ -62,6 +65,13 @@ export class ConwayGolComponent implements AfterViewInit { readGrid: Node[][] = []; writeGrid: Node[][] = []; readonly executionTime = signal(0); + readonly generation = signal(0); + private readonly generationRate = new FpsCounter(); + readonly metrics = computed(() => [ + {label: 'METRICS.GENERATION', value: this.generation()}, + {label: 'METRICS.GENERATIONS_PER_SECOND', value: this.generationRate.fps()}, + {label: 'METRICS.STEP_TIME', value: this.executionTime(), unit: 'ms'} + ]); currentScenario: Scenario = Scenario.SIMPLE; readonly gameStarted = signal(false); @@ -143,6 +153,8 @@ export class ConwayGolComponent implements AfterViewInit { initializeConwayGrid = (grid: Node[][]): void => { this.gameStarted.set(false); + this.generation.set(0); + this.generationRate.reset(); this.readGrid = grid; this.golService.applyScenario(grid, this.currentScenario); this.writeGrid = structuredClone(this.readGrid); @@ -161,6 +173,8 @@ export class ConwayGolComponent implements AfterViewInit { const changed = this.golService.nextGeneration(this.readGrid, this.writeGrid); this.swapGrids(); + this.generation.update(count => count + 1); + this.generationRate.tick(); const endTime = performance.now(); const elapsed = Number.parseFloat((endTime - startTime).toFixed(4)); this.executionTime.set(elapsed); @@ -171,6 +185,7 @@ export class ConwayGolComponent implements AfterViewInit { await this.delay(delta); } this.executionTime.set(0); + this.generationRate.reset(); } private swapGrids() { diff --git a/src/app/pages/algorithms/four-color/four-color.component.html b/src/app/pages/algorithms/four-color/four-color.component.html index 16c3aae..80e9cd0 100644 --- a/src/app/pages/algorithms/four-color/four-color.component.html +++ b/src/app/pages/algorithms/four-color/four-color.component.html @@ -47,6 +47,8 @@ {{ 'FOUR_COLOR.COLOR_4' | translate }}
+ +
+
Math.max(this.terms().length, 1)); readonly usedTerms = computed(() => this.terms().slice(0, this.circleCount())); + private readonly fpsCounter = new FpsCounter(); + readonly metrics = computed(() => [ + {label: 'METRICS.FPS', value: this.fpsCounter.fps()}, + {label: 'METRICS.SAMPLES', value: SAMPLE_COUNT} + ]); protected readonly Preset = Preset; protected readonly MIN_SPEED = MIN_SPEED; @@ -163,6 +171,7 @@ export class FourierComponent implements AfterViewInit, OnDestroy { } private readonly frame = (now: number): void => { + this.fpsCounter.tick(now); const dt = this.lastFrameTime ? (now - this.lastFrameTime) / 1000 : 0; this.lastFrameTime = now; const positions = this.advance(dt); diff --git a/src/app/pages/algorithms/fractal/fractal.component.html b/src/app/pages/algorithms/fractal/fractal.component.html index 5a530cd..47a041b 100644 --- a/src/app/pages/algorithms/fractal/fractal.component.html +++ b/src/app/pages/algorithms/fractal/fractal.component.html @@ -40,6 +40,7 @@
+ (() => [ + {label: 'METRICS.FPS', value: this.fpsCounter.fps()}, + {label: 'METRICS.BACKEND', value: this.backend()}, + {label: 'METRICS.ZOOM', value: this.sliderValue(), unit: '×'}, + {label: 'METRICS.MAX_ITERATIONS', value: this.maxIterations()} + ]); juliaReal = -0.7; juliaImag = 0.27015; // --- Render Callback --- onRender: RenderCallback = (material: ShaderMaterial) => { + this.fpsCounter.tick(); material.setVector2("center", new Vector2(this.offsetX, this.offsetY)); material.setFloat("zoom", this.zoom); - material.setInt("maxIterations", this.maxIterations); + material.setInt("maxIterations", this.maxIterations()); material.setInt("algorithm", this.selectedAlgorithm); material.setInt("colorScheme", this.selectedColorScheme); material.setVector2("juliaC", new Vector2(this.juliaReal, this.juliaImag)); @@ -153,10 +166,12 @@ export class FractalComponent implements OnInit { onReset(): void { ({zoom: this.zoom, offsetX: this.offsetX, offsetY: this.offsetY} = initialView(this.selectedAlgorithm)); - this.maxIterations = 100; + this.maxIterations.set(100); + this.sliderValue.set(this.zoom); } onSceneReady(event: SceneEventData): void { + this.backend.set(event.gpuTier === 'webgpu' ? 'WebGPU' : 'WebGL'); event.scene.onPointerObservable.add((pointerInfo) => { switch (pointerInfo.type) { @@ -240,11 +255,11 @@ export class FractalComponent implements OnInit { newZoom )); this.sliderValue.set(this.zoom); - this.maxIterations = Math.min(iterationsForZoom(this.zoom), 3000); + this.maxIterations.set(Math.min(iterationsForZoom(this.zoom), 3000)); } onSliderChange(newValue: number): void { this.zoom = clampZoom(newValue, this.minZoomValue, this.maxZoomValue); - this.maxIterations = iterationsForZoom(this.zoom); + this.maxIterations.set(iterationsForZoom(this.zoom)); } } diff --git a/src/app/pages/algorithms/fractal3d/fractal3d.component.html b/src/app/pages/algorithms/fractal3d/fractal3d.component.html index 7d424d9..e0dbab8 100644 --- a/src/app/pages/algorithms/fractal3d/fractal3d.component.html +++ b/src/app/pages/algorithms/fractal3d/fractal3d.component.html @@ -11,9 +11,11 @@ + diff --git a/src/app/pages/algorithms/fractal3d/fractal3d.component.ts b/src/app/pages/algorithms/fractal3d/fractal3d.component.ts index ee56a71..41a1f61 100644 --- a/src/app/pages/algorithms/fractal3d/fractal3d.component.ts +++ b/src/app/pages/algorithms/fractal3d/fractal3d.component.ts @@ -1,6 +1,6 @@ -import {Component} from '@angular/core'; +import {Component, computed, signal} from '@angular/core'; import {ArcRotateCamera, Camera, ShaderMaterial} from '@babylonjs/core'; -import {MANDELBULB_FRAGMENT, MANDELBULB_VERTEX} from './fractal3d.shader'; +import {MANDELBULB_FRAGMENT, MANDELBULB_VERTEX, MAX_RAY_STEPS} from './fractal3d.shader'; import {Information} from '../information/information'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {TranslatePipe} from '@ngx-translate/core'; @@ -8,7 +8,9 @@ import {AlgorithmInformation} from '../information/information.models'; import {SOURCE_PATHS} from '../information/source-paths'; import {UrlConstants} from '../../../constants/UrlConstants'; import {MatButton} from '@angular/material/button'; -import {BabylonCanvas, RenderCallback, RenderConfig} from '../../../shared/components/render-canvas/babylon-canvas.component'; +import {BabylonCanvas, RenderCallback, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component'; +import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; +import {FpsCounter} from '../../../shared/fps-counter'; @Component({ selector: 'app-fractal3d', @@ -20,7 +22,8 @@ import {BabylonCanvas, RenderCallback, RenderConfig} from '../../../shared/compo MatCardTitle, TranslatePipe, MatButton, - BabylonCanvas + BabylonCanvas, + MetricsComponent ], templateUrl: './fractal3d.component.html', styleUrl: './fractal3d.component.scss', @@ -66,8 +69,16 @@ export class Fractal3dComponent { private oldType = 0; public currentFractalType = 0; + private readonly fpsCounter = new FpsCounter(); + readonly backend = signal('–'); + readonly metrics = computed(() => [ + {label: 'METRICS.FPS', value: this.fpsCounter.fps()}, + {label: 'METRICS.BACKEND', value: this.backend()}, + {label: 'METRICS.RAY_STEPS', value: MAX_RAY_STEPS} + ]); onRender: RenderCallback = (material: ShaderMaterial, camera: Camera) => { + this.fpsCounter.tick(); this.time += 0.005; if (this.oldType != this.currentFractalType && camera instanceof ArcRotateCamera) { @@ -81,6 +92,10 @@ export class Fractal3dComponent { }; + onSceneReady(event: SceneEventData): void { + this.backend.set(event.gpuTier === 'webgpu' ? 'WebGPU' : 'WebGL'); + } + onFractalTypeChange(type: number): void { this.currentFractalType = type; } diff --git a/src/app/pages/algorithms/fractal3d/fractal3d.shader.ts b/src/app/pages/algorithms/fractal3d/fractal3d.shader.ts index 30354bc..4112002 100644 --- a/src/app/pages/algorithms/fractal3d/fractal3d.shader.ts +++ b/src/app/pages/algorithms/fractal3d/fractal3d.shader.ts @@ -1,4 +1,6 @@ -export const MANDELBULB_VERTEX = /* glsl */` +export const MAX_RAY_STEPS = 128; + +export const MANDELBULB_VERTEX = /* glsl */` precision highp float; attribute vec3 position; attribute vec2 uv; @@ -151,7 +153,7 @@ export const MANDELBULB_FRAGMENT = /* glsl */` float t = max(tEnter, 0.0); float tMax = tExit; - for (int i = 0; i < 128; i++) { + for (int i = 0; i < ${MAX_RAY_STEPS}; i++) { vec3 pos = ro + t * rd; float d = map(pos); diff --git a/src/app/pages/algorithms/pathfinding/labyrinth/labyrinth.component.html b/src/app/pages/algorithms/pathfinding/labyrinth/labyrinth.component.html index 3d2d88c..fe20e63 100644 --- a/src/app/pages/algorithms/pathfinding/labyrinth/labyrinth.component.html +++ b/src/app/pages/algorithms/pathfinding/labyrinth/labyrinth.component.html @@ -21,10 +21,7 @@ {{ 'PATHFINDING.VISITED' | translate }} {{ 'PATHFINDING.PATH' | translate }} -
-

{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength() }}

-

{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime() | number:'1.2-2' }} ms

-
+ (() => [ + {label: 'PATHFINDING.VISITED_NODES', value: this.visitedCount()}, + {label: 'PATHFINDING.PATH_LENGTH', value: this.pathLength()}, + {label: 'PATHFINDING.EXECUTION_TIME', value: this.executionTime(), unit: 'ms'} + ]); + private readonly animator = new PathfindingAnimator( node => this.genericGridComponent?.drawNode(node), () => this.animationSpeed @@ -119,6 +126,7 @@ export class LabyrinthComponent implements AfterViewInit { const endTime = performance.now(); this.pathLength.set(PathfindingAnimator.pathLengthLabel(result)); + this.visitedCount.set(result.visitedNodesInOrder.length); this.executionTime.set(endTime - startTime); this.animator.animate(result); diff --git a/src/app/pages/algorithms/pathfinding/pathfinding.component.html b/src/app/pages/algorithms/pathfinding/pathfinding.component.html index 252d71e..465d594 100644 --- a/src/app/pages/algorithms/pathfinding/pathfinding.component.html +++ b/src/app/pages/algorithms/pathfinding/pathfinding.component.html @@ -60,17 +60,15 @@ {{ 'PATHFINDING.PATH' | translate }} @if (!compareMode()) { -
-

{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength() }}

-

{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime() | number:'1.2-2' }} ms

-
+ }
@if (compareMode()) { -

A* · {{ 'PATHFINDING.VISITED_NODES' | translate }}: {{ astarStats()?.visitedCount }} · {{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ astarStats()?.pathLength }}

+

A*

+ }
-

Dijkstra · {{ 'PATHFINDING.VISITED_NODES' | translate }}: {{ dijkstraStats()?.visitedCount }} · {{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ dijkstraStats()?.pathLength }}

+

Dijkstra

+ (null); readonly executionTime = signal(0); readonly compareMode = signal(false); readonly astarStats = signal(null); readonly dijkstraStats = signal(null); + readonly metrics = computed(() => [ + ...this.runMetrics(this.runStats()), + {label: 'PATHFINDING.EXECUTION_TIME', value: this.executionTime(), unit: 'ms'} + ]); + readonly astarMetrics = computed(() => this.runMetrics(this.astarStats())); + readonly dijkstraMetrics = computed(() => this.runMetrics(this.dijkstraStats())); + private readonly animator = new PathfindingAnimator( node => this.genericGridComponent?.drawNode(node), () => this.animationSpeed @@ -251,7 +260,7 @@ export class PathfindingComponent implements AfterViewInit { const result = this.pathfindingService.findPath(algorithm, this.grid, this.startNode!, this.endNode!); const endTime = performance.now(); - this.pathLength.set(PathfindingAnimator.pathLengthLabel(result)); + this.runStats.set(this.toRunStats(result)); this.executionTime.set(endTime - startTime); this.animator.animate(result); @@ -318,6 +327,13 @@ export class PathfindingComponent implements AfterViewInit { this.compareAnimator.stop(); } + private runMetrics(stats: PathfindingRunStats | null): Metric[] { + return [ + {label: 'PATHFINDING.VISITED_NODES', value: stats?.visitedCount ?? 0}, + {label: 'PATHFINDING.PATH_LENGTH', value: stats?.pathLength ?? '0'} + ]; + } + private toRunStats(result: PathfindingResult): PathfindingRunStats { return {visitedCount: result.visitedNodesInOrder.length, pathLength: PathfindingAnimator.pathLengthLabel(result)}; } diff --git a/src/app/pages/algorithms/pendulum/pendulum.component.html b/src/app/pages/algorithms/pendulum/pendulum.component.html index b9c5950..a858c0a 100644 --- a/src/app/pages/algorithms/pendulum/pendulum.component.html +++ b/src/app/pages/algorithms/pendulum/pendulum.component.html @@ -62,6 +62,7 @@ M2
+ (() => [ + {label: 'METRICS.FPS', value: this.fpsCounter.fps()}, + {label: 'METRICS.BACKEND', value: this.backend()}, + {label: 'METRICS.TIME_STEP', value: this.loop.dt, fractionDigits: 3} + ]); + private currentSceneData: SceneEventData | null = null; private strategy: PendulumSimulationStrategy | null = null; @@ -150,6 +161,7 @@ class PendulumComponent { ? new PendulumGpuStrategy() : new PendulumCpuStrategy(); + this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU'); this.strategy.init(scene, engine); this.startParamUpdateLoop(scene); } @@ -157,6 +169,7 @@ class PendulumComponent { private startParamUpdateLoop(scene: any): void { scene.onAfterRenderObservable.clear(); scene.onAfterRenderObservable.add(() => { + this.fpsCounter.tick(); this.loop.time += this.loop.dt; if (this.strategy) { diff --git a/src/app/pages/algorithms/sorting/service/sorting.service.spec.ts b/src/app/pages/algorithms/sorting/service/sorting.service.spec.ts index 5876744..eeb2d07 100644 --- a/src/app/pages/algorithms/sorting/service/sorting.service.spec.ts +++ b/src/app/pages/algorithms/sorting/service/sorting.service.spec.ts @@ -54,4 +54,37 @@ describe('SortingService', () => { expect(input.map((item) => item.value)).toEqual(unsorted); }); } + + describe('counters', () => { + const last = (snapshots: SortSnapshot[]) => snapshots[snapshots.length - 1]; + + it('bubble sort counts every comparison and two writes per swap', () => { + const final = last(service.bubbleSort(toSortData([3, 2, 1]))); + + expect(final.comparisons).toBe(3); + expect(final.writes).toBe(6); + }); + + it('bubble sort writes nothing on sorted input', () => { + expect(last(service.bubbleSort(toSortData([1, 2, 3]))).writes).toBe(0); + }); + + it('counters restart with every run', () => { + service.bubbleSort(toSortData([3, 2, 1])); + + expect(last(service.bubbleSort(toSortData([1, 2, 3]))).comparisons).toBe(3); + }); + + for (const algorithm of ['bubbleSort', 'cocktailSort', 'quickSort', 'heapSort', 'timSort'] as const) { + it(`${algorithm} counts comparisons and never lets a counter decrease`, () => { + const snapshots = service[algorithm](toSortData(unsorted)); + + expect(last(snapshots).comparisons).toBeGreaterThan(0); + for (let i = 1; i < snapshots.length; i++) { + expect(snapshots[i].comparisons).toBeGreaterThanOrEqual(snapshots[i - 1].comparisons); + expect(snapshots[i].writes).toBeGreaterThanOrEqual(snapshots[i - 1].writes); + } + }); + } + }); }); diff --git a/src/app/pages/algorithms/sorting/service/sorting.service.ts b/src/app/pages/algorithms/sorting/service/sorting.service.ts index fccdba1..ebfc3ce 100644 --- a/src/app/pages/algorithms/sorting/service/sorting.service.ts +++ b/src/app/pages/algorithms/sorting/service/sorting.service.ts @@ -6,16 +6,27 @@ import {SortData, SortSnapshot} from '../sorting.models'; }) export class SortingService { + private comparisons = 0; + private writes = 0; + + private startRun(array: SortData[]): SortData[] { + this.comparisons = 0; + this.writes = 0; + return array.map(item => ({ ...item })); + } + private createSnapshot(array: SortData[]): SortSnapshot { return { - array: array.map(item => ({ ...item })) + array: array.map(item => ({ ...item })), + comparisons: this.comparisons, + writes: this.writes }; } // --- BUBBLE SORT --- bubbleSort(array: SortData[]): SortSnapshot[] { const snapshots: SortSnapshot[] = []; - const arr = array.map(item => ({ ...item })); + const arr = this.startRun(array); const n = arr.length; snapshots.push(this.createSnapshot(arr)); @@ -26,10 +37,9 @@ export class SortingService { arr[j + 1].state = 'comparing'; snapshots.push(this.createSnapshot(arr)); + this.comparisons++; if (arr[j].value > arr[j + 1].value) { - const temp = arr[j].value; - arr[j].value = arr[j + 1].value; - arr[j + 1].value = temp; + this.swap(arr, j, j + 1); snapshots.push(this.createSnapshot(arr)); } @@ -51,7 +61,7 @@ export class SortingService { cocktailSort(array: SortData[]): SortSnapshot[] { const snapshots: SortSnapshot[] = []; - const arr = array.map(item => ({ ...item })); + const arr = this.startRun(array); snapshots.push(this.createSnapshot(arr)); let start = -1; @@ -91,10 +101,9 @@ export class SortingService { secondValue.state = 'comparing'; snapshots.push(this.createSnapshot(arr)); + this.comparisons++; if (firstValue.value > secondValue.value) { - const temp = firstValue.value; - firstValue.value = secondValue.value; - secondValue.value = temp; + this.swap(arr, i, i + 1); changed = true; } firstValue.state = 'unsorted'; @@ -106,7 +115,7 @@ export class SortingService { // --- QUICK SORT --- quickSort(array: SortData[]): SortSnapshot[] { const snapshots: SortSnapshot[] = []; - const arr = array.map(item => ({ ...item })); + const arr = this.startRun(array); snapshots.push(this.createSnapshot(arr)); this.quickSortHelper(arr, 0, arr.length - 1, snapshots); @@ -140,6 +149,7 @@ export class SortingService { arr[j].state = 'comparing'; snapshots.push(this.createSnapshot(arr)); + this.comparisons++; if (arr[j].value < pivot.value) { i++; this.swap(arr, i, j); @@ -159,7 +169,7 @@ export class SortingService { // --- HEAP SORT --- heapSort(array: SortData[]): SortSnapshot[] { const snapshots: SortSnapshot[] = []; - const arr = array.map(item => ({ ...item })); + const arr = this.startRun(array); const n = arr.length; snapshots.push(this.createSnapshot(arr)); @@ -197,6 +207,7 @@ export class SortingService { arr[largest].state = 'comparing'; snapshots.push(this.createSnapshot(arr)); + this.comparisons++; if (arr[l].value > arr[largest].value) { largest = l; } @@ -209,6 +220,7 @@ export class SortingService { arr[largest].state = 'comparing'; snapshots.push(this.createSnapshot(arr)); + this.comparisons++; if (arr[r].value > arr[largest].value) { largest = r; } @@ -227,7 +239,7 @@ export class SortingService { // --- TIM SORT --- timSort(array: SortData[]): SortSnapshot[] { const snapshots: SortSnapshot[] = []; - const arr = array.map(item => ({ ...item })); + const arr = this.startRun(array); const n = arr.length; const RUN = 32; @@ -263,15 +275,21 @@ export class SortingService { snapshots.push(this.createSnapshot(arr)); let j = i - 1; - while (j >= left && arr[j].value > tempValue) { + while (j >= left) { + this.comparisons++; + if (arr[j].value <= tempValue) { + break; + } arr[j].state = 'comparing'; arr[j + 1].value = arr[j].value; + this.writes++; arr[j].state = 'unsorted'; snapshots.push(this.createSnapshot(arr)); j--; } arr[j + 1].value = tempValue; + this.writes++; arr[i].state = 'unsorted'; snapshots.push(this.createSnapshot(arr)); } @@ -295,6 +313,7 @@ export class SortingService { arr[rightSourceIdx].state = 'unsorted'; + this.comparisons++; if (leftPart[i].value <= rightPart[j].value) { arr[k].value = leftPart[i].value; i++; @@ -302,6 +321,7 @@ export class SortingService { arr[k].value = rightPart[j].value; j++; } + this.writes++; arr[k].state = 'unsorted'; k++; @@ -310,12 +330,14 @@ export class SortingService { while (i < leftPart.length) { arr[k].value = leftPart[i].value; + this.writes++; i++; k++; } while (j < rightPart.length) { arr[k].value = rightPart[j].value; + this.writes++; j++; k++; } @@ -325,5 +347,6 @@ export class SortingService { const temp = arr[i].value; arr[i].value = arr[j].value; arr[j].value = temp; + this.writes += 2; } } diff --git a/src/app/pages/algorithms/sorting/sorting.component.html b/src/app/pages/algorithms/sorting/sorting.component.html index 3b9de29..e9c7491 100644 --- a/src/app/pages/algorithms/sorting/sorting.component.html +++ b/src/app/pages/algorithms/sorting/sorting.component.html @@ -43,9 +43,7 @@ -
-

{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime() }} ms

-
+
@for (item of sortArray(); track $index) {
{ expect(heights()).toEqual(initial); expect(bars().some(bar => bar.nativeElement.classList.contains('bg-[#ffeb3b]'))).toBe(false); }); + + it('shows the counters of the last snapshot and zeroes them on reset', () => { + const component = fixture.componentInstance; + + component.startSorting(); + vi.runAllTimers(); + + expect(component.comparisons()).toBeGreaterThan(0); + expect(component.writes()).toBeGreaterThan(0); + + component.resetSorting(); + + expect(component.comparisons()).toBe(0); + expect(component.writes()).toBe(0); + }); }); diff --git a/src/app/pages/algorithms/sorting/sorting.component.ts b/src/app/pages/algorithms/sorting/sorting.component.ts index ee2d874..778fdc0 100644 --- a/src/app/pages/algorithms/sorting/sorting.component.ts +++ b/src/app/pages/algorithms/sorting/sorting.component.ts @@ -1,4 +1,4 @@ -import {Component, inject, OnInit, signal} from '@angular/core'; +import {Component, computed, inject, OnInit, signal} from '@angular/core'; import { CommonModule } from '@angular/common'; import {MatCardModule} from "@angular/material/card"; import {MatFormFieldModule} from "@angular/material/form-field"; @@ -15,9 +15,10 @@ import {UrlConstants} from '../../../constants/UrlConstants'; import {AlgorithmInformation} from '../information/information.models'; import {SOURCE_PATHS} from '../information/source-paths'; import {Information} from '../information/information'; +import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; @Component({ selector: 'app-sorting', - imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information], + imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information, MetricsComponent], templateUrl: './sorting.component.html', styleUrl: './sorting.component.scss' }) @@ -77,6 +78,13 @@ export class SortingComponent implements OnInit { animationSpeed = 100; // Milliseconds per step readonly selectedAlgorithm = signal(this.algoInformation.entries[0].name); readonly executionTime = signal(0); + readonly comparisons = signal(0); + readonly writes = signal(0); + readonly metrics = computed(() => [ + {label: 'METRICS.COMPARISONS', value: this.comparisons()}, + {label: 'METRICS.WRITES', value: this.writes()}, + {label: 'SORTING.EXECUTION_TIME', value: this.executionTime(), unit: 'ms'} + ]); readonly isSoundEnabled = signal(false); ngOnInit(): void { @@ -154,6 +162,8 @@ export class SortingComponent implements OnInit { snapshots.forEach((snapshot, index) => { const id = setTimeout(() => { this.sortArray.set(snapshot.array); + this.comparisons.set(snapshot.comparisons); + this.writes.set(snapshot.writes); if (this.isSoundEnabled()) { // Play a tone for each comparing bar (max 2 at once to avoid noise) @@ -183,5 +193,7 @@ export class SortingComponent implements OnInit { resetSorting(): void { this.stopAnimations(); this.resetSortState(); + this.comparisons.set(0); + this.writes.set(0); } } diff --git a/src/app/pages/algorithms/sorting/sorting.models.ts b/src/app/pages/algorithms/sorting/sorting.models.ts index e4d8bce..ff98125 100644 --- a/src/app/pages/algorithms/sorting/sorting.models.ts +++ b/src/app/pages/algorithms/sorting/sorting.models.ts @@ -5,4 +5,6 @@ export interface SortData { export interface SortSnapshot { array: SortData[]; + comparisons: number; + writes: number; } diff --git a/src/app/shared/components/metrics/metrics.html b/src/app/shared/components/metrics/metrics.html new file mode 100644 index 0000000..9244949 --- /dev/null +++ b/src/app/shared/components/metrics/metrics.html @@ -0,0 +1,5 @@ +
+ @for (metric of metrics(); track metric.label) { + {{ metric.label | translate }}: {{ format(metric) }}@if (metric.unit) { {{ metric.unit }}} + } +
diff --git a/src/app/shared/components/metrics/metrics.spec.ts b/src/app/shared/components/metrics/metrics.spec.ts new file mode 100644 index 0000000..b5f6c03 --- /dev/null +++ b/src/app/shared/components/metrics/metrics.spec.ts @@ -0,0 +1,46 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideZonelessChangeDetection} from '@angular/core'; +import {provideTranslateService} from '@ngx-translate/core'; +import {LanguageService} from '../../../service/language.service'; +import {Metric, MetricsComponent} from './metrics'; + +describe('MetricsComponent', () => { + let fixture: ComponentFixture; + + function render(metrics: Metric[]): string { + fixture.componentRef.setInput('metrics', metrics); + fixture.detectChanges(); + return fixture.nativeElement.textContent.replace(/\s+/g, ' ').trim(); + } + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection(), provideTranslateService({lang: 'en', fallbackLang: 'en'})] + }); + fixture = TestBed.createComponent(MetricsComponent); + }); + + it('renders label, value and unit', () => { + expect(render([{label: 'METRICS.FPS', value: 60, unit: 'ms'}])).toBe('METRICS.FPS: 60 ms'); + }); + + it('formats thousands per language', () => { + const language = TestBed.inject(LanguageService); + + language.lang.set('en'); + expect(render([{label: 'A', value: 39402}])).toContain('39,402'); + + language.lang.set('de'); + expect(render([{label: 'A', value: 39402}])).toContain('39.402'); + }); + + it('formats small decimals per language with the requested fraction digits', () => { + TestBed.inject(LanguageService).lang.set('de'); + + expect(render([{label: 'A', value: 0.015, fractionDigits: 3}])).toContain('0,015'); + }); + + it('shows text values as they are', () => { + expect(render([{label: 'A', value: 'WebGPU'}])).toContain('WebGPU'); + }); +}); diff --git a/src/app/shared/components/metrics/metrics.ts b/src/app/shared/components/metrics/metrics.ts new file mode 100644 index 0000000..c54fcbe --- /dev/null +++ b/src/app/shared/components/metrics/metrics.ts @@ -0,0 +1,28 @@ +import {Component, inject, input} from '@angular/core'; +import {TranslatePipe} from '@ngx-translate/core'; +import {LanguageService} from '../../../service/language.service'; + +export interface Metric { + /** i18n key */ + label: string; + value: number | string; + unit?: string; + /** Maximum fraction digits for numbers, default 2. */ + fractionDigits?: number; +} + +@Component({ + selector: 'app-metrics', + imports: [TranslatePipe], + templateUrl: './metrics.html' +}) +export class MetricsComponent { + private readonly language = inject(LanguageService); + + readonly metrics = input.required(); + + protected format(metric: Metric): string { + const {value, fractionDigits = 2} = metric; + return typeof value === 'number' ? value.toLocaleString(this.language.lang(), {maximumFractionDigits: fractionDigits}) : value; + } +} diff --git a/src/app/shared/fps-counter.spec.ts b/src/app/shared/fps-counter.spec.ts new file mode 100644 index 0000000..00e100d --- /dev/null +++ b/src/app/shared/fps-counter.spec.ts @@ -0,0 +1,30 @@ +import {FpsCounter} from './fps-counter'; + +describe('FpsCounter', () => { + const FRAME_MS = 1000 / 60; + + it('publishes 60 after the first 500 ms window and not before', () => { + const counter = new FpsCounter(); + const at = (frame: number) => 1 + FRAME_MS * frame; + + for (let frame = 0; at(frame) - 1 < 500; frame++) { + counter.tick(at(frame)); + } + expect(counter.fps()).toBe(0); + + counter.tick(at(30)); // 30 ticks since the window opened, 500 ms elapsed + expect(counter.fps()).toBe(60); + }); + + it('reset sets the rate back to 0', () => { + const counter = new FpsCounter(); + for (let i = 0; i <= 61; i++) { + counter.tick(1 + FRAME_MS * i); + } + expect(counter.fps()).toBeGreaterThan(0); + + counter.reset(); + + expect(counter.fps()).toBe(0); + }); +}); diff --git a/src/app/shared/fps-counter.ts b/src/app/shared/fps-counter.ts new file mode 100644 index 0000000..3495c8f --- /dev/null +++ b/src/app/shared/fps-counter.ts @@ -0,0 +1,30 @@ +import {signal} from '@angular/core'; + +/** Counts ticks and publishes a rate a few times per second, so the display never costs frame time. */ +export class FpsCounter { + readonly fps = signal(0); + private ticks = 0; + private windowStart = 0; + + constructor(private readonly intervalMs = 500) {} + + tick(now: number = performance.now()): void { + if (this.windowStart === 0) { + this.windowStart = now; + return; + } + this.ticks++; + const elapsed = now - this.windowStart; + if (elapsed >= this.intervalMs) { + this.fps.set(Math.round(this.ticks * 1000 / elapsed)); + this.ticks = 0; + this.windowStart = now; + } + } + + reset(): void { + this.ticks = 0; + this.windowStart = 0; + this.fps.set(0); + } +} diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index 620433a..3690ee9 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -533,7 +533,7 @@ "DISCLAIMER_4": "Zusätzlich gibt es fast immer einen Time-Space Trade-off (Zeit-Speicher-Kompromiss): Algorithmen, die extrem schnell sind (wie Merge Sort), benötigen oft viel zusätzlichen Arbeitsspeicher. Algorithmen, die direkt im vorhandenen Speicher arbeiten (wie Heap Sort), sparen Platz, sind aber manchmal komplexer oder minimal langsamer. Es gibt also keine \"One-Size-Fits-All\"-Lösung." }, "HOW_IT_WORKS": { - "INTRO": "Jeder Algorithmus läuft zuerst komplett durch und legt bei jedem Vergleich und jedem Tausch eine Kopie des Arrays (Snapshot) in eine Liste. Die Animation spielt die Snapshots danach mit 100 ms pro Schritt ab. Die Rechenzeit ist die Zeit für den Durchlauf samt Kopien, nicht die Animation.", + "INTRO": "Jeder Algorithmus läuft zuerst komplett durch und legt bei jedem Vergleich und jedem Tausch eine Kopie des Arrays (Snapshot) in eine Liste. Die Animation spielt die Snapshots danach mit 100 ms pro Schritt ab. Die Rechenzeit ist die Zeit für den Durchlauf samt Kopien, nicht die Animation. Vergleiche und Schreibzugriffe zählen während der Animation hoch.", "ITEM_1": "Bubble Sort und Cocktail Sort: O(n²) Vergleiche, in-place. Cocktail Sort wechselt pro Durchlauf die Richtung und stoppt, sobald ein Durchlauf nichts mehr tauscht.", "ITEM_2": "Quick Sort: Lomuto-Partition mit dem letzten Element als Pivot, rekursiv. Durchschnitt O(n log n), schlechtester Fall O(n²), zum Beispiel bei schon sortierten Daten.", "ITEM_3": "Heap Sort: baut einen Max-Heap im Array auf (heapify von der Mitte nach vorn) und tauscht die Wurzel ans Ende. Immer O(n log n), kein Zusatzspeicher.", @@ -885,5 +885,26 @@ "TITLE": "Seite nicht gefunden – Andreas Dahm", "DESCRIPTION": "Die angeforderte Seite konnte nicht gefunden werden." } + }, + "METRICS": { + "FPS": "Bilder/s", + "BACKEND": "Backend", + "STEP_TIME": "Rechenzeit pro Generation", + "GENERATION": "Generation", + "GENERATIONS_PER_SECOND": "Generationen/s", + "COMPARISONS": "Vergleiche", + "WRITES": "Schreibzugriffe", + "REGIONS": "Regionen", + "BORDERS": "Grenzen", + "ATTEMPTS": "Farbversuche (Backtracking)", + "SAMPLES": "Abtastpunkte", + "ZOOM": "Zoom", + "MAX_ITERATIONS": "Max. Iterationen", + "RAY_STEPS": "Max. Strahlschritte", + "TIME_STEP": "Zeitschritt dt", + "PARTICLES": "Partikel", + "CONSTRAINTS": "Constraints", + "PHASES": "Phasen", + "SUBSTEPS": "Substeps pro Frame" } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index bc578aa..7224876 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -533,7 +533,7 @@ "DISCLAIMER_4": "Additionally, there is almost always a Time-Space Trade-off: Algorithms that are extremely fast (like Merge Sort) often require a lot of additional working memory. Algorithms that work directly in existing memory (like Heap Sort) save space but are sometimes more complex or slightly slower. Thus, there is no \"one-size-fits-all\" solution." }, "HOW_IT_WORKS": { - "INTRO": "Each algorithm runs to the end first and stores a copy of the array (snapshot) in a list at every comparison and every swap. The animation then replays the snapshots at 100 ms per step. Execution time is the run including the copies, not the animation.", + "INTRO": "Each algorithm runs to the end first and stores a copy of the array (snapshot) in a list at every comparison and every swap. The animation then replays the snapshots at 100 ms per step. Execution time is the run including the copies, not the animation. Comparisons and writes count up during the animation.", "ITEM_1": "Bubble sort and cocktail sort: O(n²) comparisons, in place. Cocktail sort changes direction every pass and stops as soon as a pass swaps nothing.", "ITEM_2": "Quick sort: Lomuto partition with the last element as pivot, recursive. Average O(n log n), worst case O(n²), for example on already sorted data.", "ITEM_3": "Heap sort: builds a max heap inside the array (heapify from the middle to the front) and swaps the root to the end. Always O(n log n), no extra memory.", @@ -885,5 +885,26 @@ "TITLE": "Page not found – Andreas Dahm", "DESCRIPTION": "The requested page could not be found." } + }, + "METRICS": { + "FPS": "Frames/s", + "BACKEND": "Backend", + "STEP_TIME": "Compute time per generation", + "GENERATION": "Generation", + "GENERATIONS_PER_SECOND": "Generations/s", + "COMPARISONS": "Comparisons", + "WRITES": "Writes", + "REGIONS": "Regions", + "BORDERS": "Borders", + "ATTEMPTS": "Color attempts (backtracking)", + "SAMPLES": "Sample points", + "ZOOM": "Zoom", + "MAX_ITERATIONS": "Max. iterations", + "RAY_STEPS": "Max. ray steps", + "TIME_STEP": "Time step dt", + "PARTICLES": "Particles", + "CONSTRAINTS": "Constraints", + "PHASES": "Phases", + "SUBSTEPS": "Substeps per frame" } }