Optimize Conway and generic grid rendering

Conway GOL: add executionTime tracking and display; switch to double-buffered read/write grids with structuredClone initialization; refactor life update logic (checkLifeRules, swapGrids) to avoid mutating the source while computing the next generation; adjust per-frame delay to account for execution time; increase MAX_GRID_SIZE from 100 to 200; fix grid binding to use readGrid so UI reflects internal state.

Generic grid: add backgroundColor input and use it to clear canvas each frame; only draw cells whose color differs from background and draw grid lines conditionally based on node size to reduce overdraw; adjust drawNode to only stroke borders for larger nodes.

Templates: set backgroundColor='lightgray' for Conway and Pathfinding grid usages; display execution time in Conway UI.

Misc: remove a debug console.log in sorting component. These changes improve rendering performance, reduce flicker, and surface per-frame timing for tuning the Game of Life simulation.
This commit is contained in:
2026-02-07 09:52:58 +01:00
parent 16cc8afd4a
commit 70ed047059
6 changed files with 100 additions and 41 deletions

View File

@@ -33,6 +33,7 @@
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }} <mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
</button> </button>
} }
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
</div> </div>
<div class="grid-size"> <div class="grid-size">
<mat-form-field appearance="outline" class="grid-field"> <mat-form-field appearance="outline" class="grid-field">
@@ -69,6 +70,7 @@
(keyup.enter)="applySpeed()" (keyup.enter)="applySpeed()"
/> />
</mat-form-field> </mat-form-field>
</div> </div>
<div class="legend"> <div class="legend">
<span><span class="legend-color alive"></span> {{ 'GOL.ALIVE' | translate }}</span> <span><span class="legend-color alive"></span> {{ 'GOL.ALIVE' | translate }}</span>
@@ -84,7 +86,8 @@
[createNodeFn]="createConwayNode" [createNodeFn]="createConwayNode"
[getNodeColorFn]="getConwayNodeColor" [getNodeColorFn]="getConwayNodeColor"
[applySelectionFn]="applyConwaySelection" [applySelectionFn]="applyConwaySelection"
(gridChange)="grid = $event" [backgroundColor]="'lightgray'"
(gridChange)="readGrid = $event"
></app-generic-grid> ></app-generic-grid>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

View File

@@ -16,7 +16,7 @@ export const DEFAULT_GRID_ROWS = 50;
export const DEFAULT_GRID_COLS = 50; export const DEFAULT_GRID_COLS = 50;
export const MIN_GRID_SIZE = 20; export const MIN_GRID_SIZE = 20;
export const MAX_GRID_SIZE = 100; export const MAX_GRID_SIZE = 200;
export const DEFAULT_TIME_PER_GENERATION = 30; export const DEFAULT_TIME_PER_GENERATION = 30;
export const MIN_TIME_PER_GENERATION = 20; export const MIN_TIME_PER_GENERATION = 20;

View File

@@ -54,7 +54,9 @@ export class ConwayGol implements AfterViewInit {
protected readonly MAX_GRID_SIZE = MAX_GRID_SIZE; protected readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
protected readonly MAX_GRID_PX = MAX_GRID_PX; protected readonly MAX_GRID_PX = MAX_GRID_PX;
grid: Node[][] = []; readGrid: Node[][] = [];
writeGrid: Node[][] = [];
executionTime = 0;
currentScenario: Scenario = Scenario.SIMPLE; currentScenario: Scenario = Scenario.SIMPLE;
readonly gameStarted = signal(false); readonly gameStarted = signal(false);
@@ -96,45 +98,42 @@ export class ConwayGol implements AfterViewInit {
}; };
getConwayNodeColor = (node: Node): string => { getConwayNodeColor = (node: Node): string => {
if (node.alive) { return node.alive ? 'black' : 'lightgray';
return 'black';
}
return 'lightgray';
}; };
applyConwaySelection = (pos: GridPos, grid: Node[][]): void => { applyConwaySelection = (pos: GridPos, grid: Node[][]): void => {
this.grid = grid; // Keep internal grid in sync this.readGrid = grid; // Keep internal grid in sync
const node = grid[pos.row][pos.col]; const node = grid[pos.row][pos.col];
node.alive = !node.alive; // Toggle alive status node.alive = !node.alive; // Toggle alive status
}; };
initializeConwayGrid = (grid: Node[][]): void => { initializeConwayGrid = (grid: Node[][]): void => {
this.gameStarted.set(false); this.gameStarted.set(false);
this.grid = grid; this.readGrid = grid;
switch(this.currentScenario) { switch(this.currentScenario) {
case Scenario.RANDOM: this.setupRandomLives(); break; case Scenario.RANDOM: this.setupRandomLives(); break;
case Scenario.SIMPLE: this.setupSimpleLive(); break; case Scenario.SIMPLE: this.setupSimpleLive(); break;
case Scenario.PULSAR: this.setupPulsar(); break; case Scenario.PULSAR: this.setupPulsar(); break;
case Scenario.GUN: this.setupGliderGun(); break; case Scenario.GUN: this.setupGliderGun(); break;
} }
this.writeGrid = structuredClone(this.readGrid);
}; };
// --- Conway-specific logic (kept local) --- // --- Conway-specific logic (kept local) ---
setupRandomLives(): void { setupRandomLives(): void {
for (let row = 0; row < this.gridRows; row++) { for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) { for (let col = 0; col < this.gridCols; col++) {
this.grid[row][col].alive = Math.random() <= LIVE_SPAWN_PROBABILITY; this.readGrid[row][col].alive = Math.random() <= LIVE_SPAWN_PROBABILITY;
} }
} }
} }
setupSimpleLive(): void { setupSimpleLive(): void {
this.grid[3][4].alive = true; this.readGrid[3][4].alive = true;
this.grid[4][5].alive = true; this.readGrid[4][5].alive = true;
this.grid[5][3].alive = true; this.readGrid[5][3].alive = true;
this.grid[5][4].alive = true; this.readGrid[5][4].alive = true;
this.grid[5][5].alive = true; this.readGrid[5][5].alive = true;
} }
setupPulsar(): void { setupPulsar(): void {
@@ -181,39 +180,44 @@ export class ConwayGol implements AfterViewInit {
this.gameStarted.set(true); this.gameStarted.set(true);
let lifeIsDead = false; let lifeIsDead = false;
while (this.gameStarted()){ while (this.gameStarted()){
let gridClone = structuredClone(this.grid); const startTime = performance.now();
lifeIsDead = true; lifeIsDead = true;
for (let row = 0; row < this.gridRows; row++) { for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) { for (let col = 0; col < this.gridCols; col++) {
lifeIsDead = this.checkLifeRules(row, col, gridClone, lifeIsDead); lifeIsDead = this.checkLifeRules(row, col, this.writeGrid) && lifeIsDead;
} }
} }
this.swapGrid(gridClone); this.swapGrids();
const endTime = performance.now();
this.executionTime = Number.parseFloat((endTime - startTime).toFixed(4));
if (lifeIsDead){ if (lifeIsDead){
this.gameStarted.set(false); this.gameStarted.set(false);
} }
await this.delay(this.lifeSpeed); const delta = Math.max(this.lifeSpeed - this.executionTime, 0);
await this.delay(delta);
} }
this.executionTime = 0;
} }
private checkLifeRules(row: number, col: number, gridClone: Node[][], lifeIsDead: boolean) { private checkLifeRules(row: number, col: number, writeGrid: Node[][]): boolean {
const itsMe = this.grid[row][col]; const currentCell = this.readGrid[row][col];
let aliveNeighbors = this.howManyNeighborsAreLiving(row, col); const aliveNeighbors = this.howManyNeighborsAreLiving(row, col);
if (itsMe.alive && (aliveNeighbors < 2 || aliveNeighbors > 3)) { const oldLifeState = currentCell.alive;
gridClone[row][col].alive = false;
lifeIsDead = false; const nextStateAlive = (currentCell.alive && (aliveNeighbors === 2 || aliveNeighbors === 3)) || (!currentCell.alive && aliveNeighbors === 3);
} else if (!itsMe.alive && aliveNeighbors === 3) { writeGrid[row][col].alive = nextStateAlive;
gridClone[row][col].alive = true;
lifeIsDead = false; //only if at least one cell changes the game is still alive
} return (nextStateAlive == oldLifeState);
return lifeIsDead;
} }
private swapGrid(gridClone: Node[][]) { private swapGrids() {
this.grid = gridClone; const tmp = this.readGrid;
this.readGrid = this.writeGrid;
this.writeGrid = tmp;
if (this.genericGridComponent) { if (this.genericGridComponent) {
this.genericGridComponent.grid = this.grid; this.genericGridComponent.grid = this.readGrid;
this.genericGridComponent.drawGrid(); this.genericGridComponent.drawGrid();
} }
} }
@@ -231,7 +235,7 @@ export class ConwayGol implements AfterViewInit {
if (nRow == row && nCol == col) { if (nRow == row && nCol == col) {
continue; continue;
} }
if (this.grid[nRow][nCol].alive) { if (this.readGrid[nRow][nCol].alive) {
aliveNeighborCount++; aliveNeighborCount++;
} }
} }
@@ -250,7 +254,7 @@ export class ConwayGol implements AfterViewInit {
private setAlive(r: number, c: number): void { private setAlive(r: number, c: number): void {
if (r >= 0 && r < this.gridRows && c >= 0 && c < this.gridCols) { if (r >= 0 && r < this.gridRows && c >= 0 && c < this.gridCols) {
this.grid[r][c].alive = true; this.readGrid[r][c].alive = true;
} }
} }
} }

View File

@@ -73,6 +73,7 @@
[createNodeFn]="createPathfindingNode" [createNodeFn]="createPathfindingNode"
[getNodeColorFn]="getPathfindingNodeColor" [getNodeColorFn]="getPathfindingNodeColor"
[applySelectionFn]="applyPathfindingSelection" [applySelectionFn]="applyPathfindingSelection"
[backgroundColor]="'lightgray'"
(gridChange)="grid = $event" (gridChange)="grid = $event"
></app-generic-grid> ></app-generic-grid>
</mat-card-content> </mat-card-content>

View File

@@ -136,7 +136,6 @@ export class SortingComponent implements OnInit {
const endTime = performance.now(); const endTime = performance.now();
this.executionTime = Number.parseFloat((endTime - startTime).toFixed(4)); this.executionTime = Number.parseFloat((endTime - startTime).toFixed(4));
console.log(snapshots.length);
this.animateSorting(snapshots); this.animateSorting(snapshots);
} }

View File

@@ -21,6 +21,7 @@ export class GenericGridComponent implements AfterViewInit {
@Input() minGridSize: number = 5; @Input() minGridSize: number = 5;
@Input() maxGridSize: number = 50; @Input() maxGridSize: number = 50;
@Input() drawNodeBorderColor: string = '#ccc'; @Input() drawNodeBorderColor: string = '#ccc';
@Input() backgroundColor: string = 'lightgray';
// Callbacks from parent component // Callbacks from parent component
@Input() createNodeFn!: (row: number, col: number) => any; @Input() createNodeFn!: (row: number, col: number) => any;
@@ -99,20 +100,71 @@ export class GenericGridComponent implements AfterViewInit {
} }
drawGrid(): void { drawGrid(): void {
this.ctx.clearRect(0, 0, this.canvas.nativeElement.width, this.canvas.nativeElement.height); if (!this.ctx || !this.grid.length) return;
const width = this.canvas.nativeElement.width;
const height = this.canvas.nativeElement.height;
const size = this.nodeSize;
this.ctx.fillStyle = this.backgroundColor;
this.ctx.fillRect(0, 0, width, height);
this.ctx.fillStyle = 'black';
for (let row = 0; row < this.gridRows; row++) { for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) { for (let col = 0; col < this.gridCols; col++) {
this.drawNode(this.grid[row][col]); const node = this.grid[row][col];
const color = this.getNodeColorFn(node);
if (color !== this.backgroundColor) {
if (this.ctx.fillStyle !== color) {
this.ctx.fillStyle = color;
}
const x = col * this.nodeSize;
const y = row * this.nodeSize;
this.ctx.fillRect(x, y, this.nodeSize, this.nodeSize);
} }
} }
} }
if (size > 2) {
this.drawGridLines(width, height);
}
}
private drawGridLines(width: number, height: number): void {
this.ctx.beginPath();
this.ctx.strokeStyle = this.drawNodeBorderColor;
this.ctx.lineWidth = 1;
for (let col = 0; col <= this.gridCols; col++) {
const x = col * this.nodeSize;
this.ctx.moveTo(x, 0);
this.ctx.lineTo(x, height);
}
for (let row = 0; row <= this.gridRows; row++) {
const y = row * this.nodeSize;
this.ctx.moveTo(0, y);
this.ctx.lineTo(width, y);
}
this.ctx.stroke();
}
drawNode(node: any): void { drawNode(node: any): void {
this.ctx.fillStyle = this.getNodeColorFn(node); this.ctx.fillStyle = this.getNodeColorFn(node);
this.ctx.fillRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize); this.ctx.fillRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize);
if (this.nodeSize > 4) {
this.ctx.strokeStyle = this.drawNodeBorderColor; this.ctx.strokeStyle = this.drawNodeBorderColor;
this.ctx.strokeRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize); this.ctx.strokeRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize);
} }
}
private getContextOrThrow(): CanvasRenderingContext2D { private getContextOrThrow(): CanvasRenderingContext2D {
const ctx = this.canvas.nativeElement.getContext('2d'); const ctx = this.canvas.nativeElement.getContext('2d');