mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-07 19:21:30 +00:00
move terrain color computation to GPU compute shader
This commit is contained in:
@@ -62,6 +62,7 @@ export class TerritoryLayer implements Layer {
|
|||||||
const currentTheme = this.game.config().theme();
|
const currentTheme = this.game.config().theme();
|
||||||
if (currentTheme !== this.theme) {
|
if (currentTheme !== this.theme) {
|
||||||
this.theme = currentTheme;
|
this.theme = currentTheme;
|
||||||
|
this.territoryRenderer?.refreshTerrain();
|
||||||
this.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +117,14 @@ export class TerritoryLayer implements Layer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for theme changes in renderLayer too (for when game is paused)
|
||||||
|
const currentTheme = this.game.config().theme();
|
||||||
|
if (currentTheme !== this.theme) {
|
||||||
|
this.theme = currentTheme;
|
||||||
|
this.territoryRenderer.refreshTerrain();
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
this.ensureTerritoryCanvasAttached(context.canvas);
|
this.ensureTerritoryCanvasAttached(context.canvas);
|
||||||
this.updateHoverHighlight();
|
this.updateHoverHighlight();
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ComputePass } from "./compute/ComputePass";
|
|||||||
import { DefendedClearPass } from "./compute/DefendedClearPass";
|
import { DefendedClearPass } from "./compute/DefendedClearPass";
|
||||||
import { DefendedUpdatePass } from "./compute/DefendedUpdatePass";
|
import { DefendedUpdatePass } from "./compute/DefendedUpdatePass";
|
||||||
import { StateUpdatePass } from "./compute/StateUpdatePass";
|
import { StateUpdatePass } from "./compute/StateUpdatePass";
|
||||||
|
import { TerrainComputePass } from "./compute/TerrainComputePass";
|
||||||
import { GroundTruthData } from "./core/GroundTruthData";
|
import { GroundTruthData } from "./core/GroundTruthData";
|
||||||
import { WebGPUDevice } from "./core/WebGPUDevice";
|
import { WebGPUDevice } from "./core/WebGPUDevice";
|
||||||
import { RenderPass } from "./render/RenderPass";
|
import { RenderPass } from "./render/RenderPass";
|
||||||
@@ -37,6 +38,7 @@ export class TerritoryRenderer {
|
|||||||
private renderPassOrder: RenderPass[] = [];
|
private renderPassOrder: RenderPass[] = [];
|
||||||
|
|
||||||
// Pass instances
|
// Pass instances
|
||||||
|
private terrainComputePass: TerrainComputePass | null = null;
|
||||||
private stateUpdatePass: StateUpdatePass | null = null;
|
private stateUpdatePass: StateUpdatePass | null = null;
|
||||||
private defendedClearPass: DefendedClearPass | null = null;
|
private defendedClearPass: DefendedClearPass | null = null;
|
||||||
private defendedUpdatePass: DefendedUpdatePass | null = null;
|
private defendedUpdatePass: DefendedUpdatePass | null = null;
|
||||||
@@ -99,15 +101,18 @@ export class TerritoryRenderer {
|
|||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Upload initial terrain texture
|
// Upload terrain data and params (terrain colors will be computed on GPU)
|
||||||
this.resources.uploadTerrain();
|
this.resources.uploadTerrainData();
|
||||||
|
this.resources.uploadTerrainParams();
|
||||||
|
|
||||||
// Create compute passes
|
// Create compute passes (terrain compute should run first)
|
||||||
|
this.terrainComputePass = new TerrainComputePass();
|
||||||
this.stateUpdatePass = new StateUpdatePass();
|
this.stateUpdatePass = new StateUpdatePass();
|
||||||
this.defendedClearPass = new DefendedClearPass();
|
this.defendedClearPass = new DefendedClearPass();
|
||||||
this.defendedUpdatePass = new DefendedUpdatePass();
|
this.defendedUpdatePass = new DefendedUpdatePass();
|
||||||
|
|
||||||
this.computePasses = [
|
this.computePasses = [
|
||||||
|
this.terrainComputePass,
|
||||||
this.stateUpdatePass,
|
this.stateUpdatePass,
|
||||||
this.defendedClearPass,
|
this.defendedClearPass,
|
||||||
this.defendedUpdatePass,
|
this.defendedUpdatePass,
|
||||||
@@ -255,6 +260,50 @@ export class TerritoryRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshTerrain(): void {
|
||||||
|
if (!this.resources || !this.device) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.resources.markTerrainParamsDirty();
|
||||||
|
if (this.terrainComputePass) {
|
||||||
|
this.terrainComputePass.markDirty();
|
||||||
|
// Immediately compute terrain to avoid blank rendering
|
||||||
|
this.computeTerrainImmediate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediately execute terrain compute pass (for theme changes).
|
||||||
|
* This ensures terrain is recomputed before the next render.
|
||||||
|
*/
|
||||||
|
private computeTerrainImmediate(): void {
|
||||||
|
if (
|
||||||
|
!this.ready ||
|
||||||
|
!this.device ||
|
||||||
|
!this.resources ||
|
||||||
|
!this.terrainComputePass
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload terrain params if needed
|
||||||
|
this.resources.uploadTerrainParams();
|
||||||
|
|
||||||
|
if (!this.terrainComputePass.needsUpdate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = this.device.device.createCommandEncoder();
|
||||||
|
this.terrainComputePass.execute(encoder, this.resources);
|
||||||
|
this.device.device.queue.submit([encoder.finish()]);
|
||||||
|
|
||||||
|
// Rebuild render pass bind group to ensure it uses the updated terrain texture
|
||||||
|
// This will be called again in render(), but doing it here ensures it's ready
|
||||||
|
if (this.territoryRenderPass) {
|
||||||
|
(this.territoryRenderPass as any).rebuildBindGroup?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform one simulation tick.
|
* Perform one simulation tick.
|
||||||
* Runs compute passes to update ground truth data.
|
* Runs compute passes to update ground truth data.
|
||||||
@@ -267,6 +316,9 @@ export class TerritoryRenderer {
|
|||||||
// Upload palette if needed
|
// Upload palette if needed
|
||||||
this.resources.uploadPalette();
|
this.resources.uploadPalette();
|
||||||
|
|
||||||
|
// Upload terrain params if needed (theme changed)
|
||||||
|
this.resources.uploadTerrainParams();
|
||||||
|
|
||||||
// Upload defense posts if needed (tracks if it was dirty before upload)
|
// Upload defense posts if needed (tracks if it was dirty before upload)
|
||||||
const wasDefensePostsDirty = (this.resources as any)
|
const wasDefensePostsDirty = (this.resources as any)
|
||||||
.needsDefensePostsUpload;
|
.needsDefensePostsUpload;
|
||||||
@@ -279,6 +331,9 @@ export class TerritoryRenderer {
|
|||||||
const numUpdates = this.stateUpdatePass
|
const numUpdates = this.stateUpdatePass
|
||||||
? ((this.stateUpdatePass as any).pendingTiles?.size ?? 0)
|
? ((this.stateUpdatePass as any).pendingTiles?.size ?? 0)
|
||||||
: 0;
|
: 0;
|
||||||
|
const needsTerrainCompute = this.terrainComputePass
|
||||||
|
? this.terrainComputePass.needsUpdate()
|
||||||
|
: false;
|
||||||
const range = this.game.config().defensePostRange();
|
const range = this.game.config().defensePostRange();
|
||||||
const rangeChanged = range !== this.resources.getLastDefenseRange();
|
const rangeChanged = range !== this.resources.getLastDefenseRange();
|
||||||
const countChanged =
|
const countChanged =
|
||||||
@@ -295,6 +350,7 @@ export class TerritoryRenderer {
|
|||||||
(hasPosts && numUpdates > 0);
|
(hasPosts && numUpdates > 0);
|
||||||
|
|
||||||
const needsCompute =
|
const needsCompute =
|
||||||
|
needsTerrainCompute === true ||
|
||||||
numUpdates > 0 ||
|
numUpdates > 0 ||
|
||||||
shouldRebuildDefended === true ||
|
shouldRebuildDefended === true ||
|
||||||
this.needsDefendedHardClear === true;
|
this.needsDefendedHardClear === true;
|
||||||
@@ -369,6 +425,33 @@ export class TerritoryRenderer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if terrain needs recomputation (e.g., theme changed)
|
||||||
|
// If so, compute it in the same command buffer before rendering
|
||||||
|
if (this.terrainComputePass?.needsUpdate()) {
|
||||||
|
this.resources.uploadTerrainParams();
|
||||||
|
|
||||||
|
// Use a single encoder to ensure compute completes before render
|
||||||
|
const encoder = this.device.device.createCommandEncoder();
|
||||||
|
|
||||||
|
// Execute terrain compute first
|
||||||
|
this.terrainComputePass.execute(encoder, this.resources);
|
||||||
|
|
||||||
|
// Then execute render passes in the same command buffer
|
||||||
|
// The render pass will rebuild its bind group, which will now use the updated terrain texture
|
||||||
|
const textureView = this.device.context.getCurrentTexture().createView();
|
||||||
|
for (const pass of this.renderPassOrder) {
|
||||||
|
if (!pass.needsUpdate()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pass.execute(encoder, this.resources, textureView);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit single command buffer with both compute and render
|
||||||
|
// This ensures compute completes before render reads the terrain texture
|
||||||
|
this.device.device.queue.submit([encoder.finish()]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const encoder = this.device.device.createCommandEncoder();
|
const encoder = this.device.device.createCommandEncoder();
|
||||||
const textureView = this.device.context.getCurrentTexture().createView();
|
const textureView = this.device.context.getCurrentTexture().createView();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { GroundTruthData } from "../core/GroundTruthData";
|
||||||
|
import { loadShader } from "../core/ShaderLoader";
|
||||||
|
import { ComputePass } from "./ComputePass";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute pass that generates terrain colors from terrain data.
|
||||||
|
* Runs once at initialization or when theme changes.
|
||||||
|
*/
|
||||||
|
export class TerrainComputePass implements ComputePass {
|
||||||
|
name = "terrain-compute";
|
||||||
|
dependencies: string[] = [];
|
||||||
|
|
||||||
|
private pipeline: GPUComputePipeline | null = null;
|
||||||
|
private bindGroupLayout: GPUBindGroupLayout | null = null;
|
||||||
|
private bindGroup: GPUBindGroup | null = null;
|
||||||
|
private device: GPUDevice | null = null;
|
||||||
|
private resources: GroundTruthData | null = null;
|
||||||
|
private needsCompute = true;
|
||||||
|
|
||||||
|
async init(device: GPUDevice, resources: GroundTruthData): Promise<void> {
|
||||||
|
this.device = device;
|
||||||
|
this.resources = resources;
|
||||||
|
|
||||||
|
const shaderCode = await loadShader("compute/terrain-compute.wgsl");
|
||||||
|
const shaderModule = device.createShaderModule({ code: shaderCode });
|
||||||
|
|
||||||
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
binding: 0,
|
||||||
|
visibility: 4 /* COMPUTE */,
|
||||||
|
buffer: { type: "uniform" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 1,
|
||||||
|
visibility: 4 /* COMPUTE */,
|
||||||
|
texture: { sampleType: "uint" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 2,
|
||||||
|
visibility: 4 /* COMPUTE */,
|
||||||
|
storageTexture: { format: "rgba8unorm" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pipeline = device.createComputePipeline({
|
||||||
|
layout: device.createPipelineLayout({
|
||||||
|
bindGroupLayouts: [this.bindGroupLayout],
|
||||||
|
}),
|
||||||
|
compute: {
|
||||||
|
module: shaderModule,
|
||||||
|
entryPoint: "main",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rebuildBindGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
needsUpdate(): boolean {
|
||||||
|
return this.needsCompute;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void {
|
||||||
|
if (!this.device || !this.pipeline || !this.bindGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapWidth = resources.getMapWidth();
|
||||||
|
const mapHeight = resources.getMapHeight();
|
||||||
|
const workgroupCountX = Math.ceil(mapWidth / 8);
|
||||||
|
const workgroupCountY = Math.ceil(mapHeight / 8);
|
||||||
|
|
||||||
|
const pass = encoder.beginComputePass();
|
||||||
|
pass.setPipeline(this.pipeline);
|
||||||
|
pass.setBindGroup(0, this.bindGroup);
|
||||||
|
pass.dispatchWorkgroups(workgroupCountX, workgroupCountY);
|
||||||
|
pass.end();
|
||||||
|
|
||||||
|
this.needsCompute = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuildBindGroup(): void {
|
||||||
|
if (
|
||||||
|
!this.device ||
|
||||||
|
!this.bindGroupLayout ||
|
||||||
|
!this.resources ||
|
||||||
|
!this.resources.terrainParamsBuffer ||
|
||||||
|
!this.resources.terrainDataTexture ||
|
||||||
|
!this.resources.terrainTexture
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bindGroup = this.device.createBindGroup({
|
||||||
|
layout: this.bindGroupLayout,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
binding: 0,
|
||||||
|
resource: { buffer: this.resources.terrainParamsBuffer },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 1,
|
||||||
|
resource: this.resources.terrainDataTexture.createView(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 2,
|
||||||
|
resource: this.resources.terrainTexture.createView(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
markDirty(): void {
|
||||||
|
this.needsCompute = true;
|
||||||
|
// Rebuild bind group in case terrain params buffer was recreated
|
||||||
|
this.rebuildBindGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.pipeline = null;
|
||||||
|
this.bindGroupLayout = null;
|
||||||
|
this.bindGroup = null;
|
||||||
|
this.device = null;
|
||||||
|
this.resources = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,12 +20,14 @@ export class GroundTruthData {
|
|||||||
// Textures
|
// Textures
|
||||||
public readonly stateTexture: GPUTexture;
|
public readonly stateTexture: GPUTexture;
|
||||||
public readonly terrainTexture: GPUTexture;
|
public readonly terrainTexture: GPUTexture;
|
||||||
|
public readonly terrainDataTexture: GPUTexture;
|
||||||
public readonly paletteTexture: GPUTexture;
|
public readonly paletteTexture: GPUTexture;
|
||||||
public readonly defendedTexture: GPUTexture;
|
public readonly defendedTexture: GPUTexture;
|
||||||
|
|
||||||
// Buffers
|
// Buffers
|
||||||
public readonly uniformBuffer: GPUBuffer;
|
public readonly uniformBuffer: GPUBuffer;
|
||||||
public readonly defenseParamsBuffer: GPUBuffer;
|
public readonly defenseParamsBuffer: GPUBuffer;
|
||||||
|
public readonly terrainParamsBuffer: GPUBuffer;
|
||||||
public updatesBuffer: GPUBuffer | null = null;
|
public updatesBuffer: GPUBuffer | null = null;
|
||||||
public defensePostsBuffer: GPUBuffer | null = null;
|
public defensePostsBuffer: GPUBuffer | null = null;
|
||||||
|
|
||||||
@@ -41,8 +43,11 @@ export class GroundTruthData {
|
|||||||
private readonly mapWidth: number;
|
private readonly mapWidth: number;
|
||||||
private readonly mapHeight: number;
|
private readonly mapHeight: number;
|
||||||
private readonly state: Uint16Array;
|
private readonly state: Uint16Array;
|
||||||
|
private readonly terrainData: Uint8Array;
|
||||||
private needsStateUpload = true;
|
private needsStateUpload = true;
|
||||||
private needsPaletteUpload = true;
|
private needsPaletteUpload = true;
|
||||||
|
private needsTerrainDataUpload = true;
|
||||||
|
private needsTerrainParamsUpload = true;
|
||||||
private paletteWidth = 1;
|
private paletteWidth = 1;
|
||||||
private defensePostsCount = 0;
|
private defensePostsCount = 0;
|
||||||
private needsDefensePostsUpload = true;
|
private needsDefensePostsUpload = true;
|
||||||
@@ -50,6 +55,7 @@ export class GroundTruthData {
|
|||||||
// Uniform data arrays
|
// Uniform data arrays
|
||||||
private readonly uniformData = new Float32Array(12);
|
private readonly uniformData = new Float32Array(12);
|
||||||
private readonly defenseParamsData = new Uint32Array(4);
|
private readonly defenseParamsData = new Uint32Array(4);
|
||||||
|
private readonly terrainParamsData = new Float32Array(24); // 6 vec4f: shore, water, shorelineWater, plainsBase, highlandBase, mountainBase
|
||||||
|
|
||||||
// View state (updated by renderer)
|
// View state (updated by renderer)
|
||||||
private viewWidth = 1;
|
private viewWidth = 1;
|
||||||
@@ -70,10 +76,12 @@ export class GroundTruthData {
|
|||||||
private readonly game: GameView,
|
private readonly game: GameView,
|
||||||
private readonly theme: Theme,
|
private readonly theme: Theme,
|
||||||
state: Uint16Array,
|
state: Uint16Array,
|
||||||
|
terrainData: Uint8Array,
|
||||||
mapWidth: number,
|
mapWidth: number,
|
||||||
mapHeight: number,
|
mapHeight: number,
|
||||||
) {
|
) {
|
||||||
this.state = state;
|
this.state = state;
|
||||||
|
this.terrainData = terrainData;
|
||||||
this.mapWidth = mapWidth;
|
this.mapWidth = mapWidth;
|
||||||
this.mapHeight = mapHeight;
|
this.mapHeight = mapHeight;
|
||||||
|
|
||||||
@@ -97,6 +105,12 @@ export class GroundTruthData {
|
|||||||
usage: UNIFORM | COPY_DST_BUF,
|
usage: UNIFORM | COPY_DST_BUF,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Terrain params: 6x vec4f = 96 bytes (shore, water, shorelineWater, plainsBase, highlandBase, mountainBase)
|
||||||
|
this.terrainParamsBuffer = device.createBuffer({
|
||||||
|
size: 96,
|
||||||
|
usage: UNIFORM | COPY_DST_BUF,
|
||||||
|
});
|
||||||
|
|
||||||
// State texture (r32uint)
|
// State texture (r32uint)
|
||||||
this.stateTexture = device.createTexture({
|
this.stateTexture = device.createTexture({
|
||||||
size: { width: mapWidth, height: mapHeight },
|
size: { width: mapWidth, height: mapHeight },
|
||||||
@@ -118,10 +132,17 @@ export class GroundTruthData {
|
|||||||
usage: COPY_DST_TEX | TEXTURE_BINDING,
|
usage: COPY_DST_TEX | TEXTURE_BINDING,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Terrain texture (rgba8unorm)
|
// Terrain texture (rgba8unorm) - output of terrain compute shader
|
||||||
this.terrainTexture = device.createTexture({
|
this.terrainTexture = device.createTexture({
|
||||||
size: { width: mapWidth, height: mapHeight },
|
size: { width: mapWidth, height: mapHeight },
|
||||||
format: "rgba8unorm",
|
format: "rgba8unorm",
|
||||||
|
usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Terrain data texture (r8uint) - input terrain data (read-only in compute shader)
|
||||||
|
this.terrainDataTexture = device.createTexture({
|
||||||
|
size: { width: mapWidth, height: mapHeight },
|
||||||
|
format: "r8uint",
|
||||||
usage: COPY_DST_TEX | TEXTURE_BINDING,
|
usage: COPY_DST_TEX | TEXTURE_BINDING,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -137,6 +158,7 @@ export class GroundTruthData {
|
|||||||
game,
|
game,
|
||||||
theme,
|
theme,
|
||||||
state,
|
state,
|
||||||
|
game.terrainDataView(),
|
||||||
game.width(),
|
game.width(),
|
||||||
game.height(),
|
game.height(),
|
||||||
);
|
);
|
||||||
@@ -212,6 +234,9 @@ export class GroundTruthData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use terrain compute shader instead. This method is kept for fallback.
|
||||||
|
*/
|
||||||
uploadTerrain(): void {
|
uploadTerrain(): void {
|
||||||
const bytesPerRow = this.mapWidth * 4;
|
const bytesPerRow = this.mapWidth * 4;
|
||||||
const paddedBytesPerRow = align(bytesPerRow, 256);
|
const paddedBytesPerRow = align(bytesPerRow, 256);
|
||||||
@@ -241,6 +266,204 @@ export class GroundTruthData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uploadTerrainData(): void {
|
||||||
|
if (!this.needsTerrainDataUpload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.needsTerrainDataUpload = false;
|
||||||
|
|
||||||
|
const bytesPerRow = this.mapWidth;
|
||||||
|
const paddedBytesPerRow = align(bytesPerRow, 256);
|
||||||
|
|
||||||
|
if (paddedBytesPerRow === bytesPerRow) {
|
||||||
|
// Direct upload if already aligned
|
||||||
|
this.device.queue.writeTexture(
|
||||||
|
{ texture: this.terrainDataTexture },
|
||||||
|
this.terrainData,
|
||||||
|
{ bytesPerRow, rowsPerImage: this.mapHeight },
|
||||||
|
{
|
||||||
|
width: this.mapWidth,
|
||||||
|
height: this.mapHeight,
|
||||||
|
depthOrArrayLayers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Row-by-row upload with padding
|
||||||
|
const row = new Uint8Array(paddedBytesPerRow);
|
||||||
|
for (let y = 0; y < this.mapHeight; y++) {
|
||||||
|
row.fill(0);
|
||||||
|
const start = y * this.mapWidth;
|
||||||
|
row.set(this.terrainData.subarray(start, start + this.mapWidth), 0);
|
||||||
|
this.device.queue.writeTexture(
|
||||||
|
{ texture: this.terrainDataTexture, origin: { x: 0, y } },
|
||||||
|
row,
|
||||||
|
{ bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 },
|
||||||
|
{ width: this.mapWidth, height: 1, depthOrArrayLayers: 1 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadTerrainParams(): void {
|
||||||
|
if (!this.needsTerrainParamsUpload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.needsTerrainParamsUpload = false;
|
||||||
|
|
||||||
|
// Sample theme colors by finding representative tiles
|
||||||
|
// We'll search for a shore tile, water tile, and compute base terrain colors
|
||||||
|
let shoreColor = { r: 204, g: 203, b: 158, a: 255 }; // Default pastel
|
||||||
|
let waterColor = { r: 70, g: 132, b: 180, a: 255 }; // Default pastel
|
||||||
|
let shorelineWaterColor = { r: 100, g: 143, b: 255, a: 255 }; // Default pastel
|
||||||
|
|
||||||
|
// Find a shore tile (land adjacent to water)
|
||||||
|
for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) {
|
||||||
|
if (this.game.isShore(i)) {
|
||||||
|
const color = this.theme.terrainColor(this.game, i);
|
||||||
|
shoreColor = color.rgba;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a deep water tile (magnitude > 5) and shoreline water
|
||||||
|
for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) {
|
||||||
|
if (this.game.isWater(i)) {
|
||||||
|
if (this.game.isShoreline(i)) {
|
||||||
|
const color = this.theme.terrainColor(this.game, i);
|
||||||
|
shorelineWaterColor = color.rgba;
|
||||||
|
} else if (this.game.magnitude(i) > 5) {
|
||||||
|
const color = this.theme.terrainColor(this.game, i);
|
||||||
|
waterColor = color.rgba;
|
||||||
|
}
|
||||||
|
if (waterColor.r !== 70 || shorelineWaterColor.r !== 100) {
|
||||||
|
// Found both, can break
|
||||||
|
if (this.game.isShoreline(i) && this.game.magnitude(i) > 5) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute terrain base colors by sampling at magnitude 0, 10, 20
|
||||||
|
// Find a plains tile (magnitude < 10, land, not shore)
|
||||||
|
let plainsColor = { r: 190, g: 220, b: 138, a: 255 };
|
||||||
|
for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) {
|
||||||
|
if (
|
||||||
|
this.game.isLand(i) &&
|
||||||
|
!this.game.isShore(i) &&
|
||||||
|
this.game.magnitude(i) < 10
|
||||||
|
) {
|
||||||
|
const color = this.theme.terrainColor(this.game, i);
|
||||||
|
plainsColor = color.rgba;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a highland tile at magnitude 10 (for accurate formula computation)
|
||||||
|
let highlandColor = { r: 200, g: 183, b: 138, a: 255 };
|
||||||
|
for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) {
|
||||||
|
if (
|
||||||
|
this.game.isLand(i) &&
|
||||||
|
!this.game.isShore(i) &&
|
||||||
|
this.game.magnitude(i) === 10
|
||||||
|
) {
|
||||||
|
const color = this.theme.terrainColor(this.game, i);
|
||||||
|
highlandColor = color.rgba;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no mag 10 found, try any highland tile
|
||||||
|
if (highlandColor.r === 200 && highlandColor.g === 183) {
|
||||||
|
for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) {
|
||||||
|
if (
|
||||||
|
this.game.isLand(i) &&
|
||||||
|
!this.game.isShore(i) &&
|
||||||
|
this.game.magnitude(i) >= 10 &&
|
||||||
|
this.game.magnitude(i) < 20
|
||||||
|
) {
|
||||||
|
const color = this.theme.terrainColor(this.game, i);
|
||||||
|
highlandColor = color.rgba;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store colors as vec4f (RGBA normalized to 0-1)
|
||||||
|
// Index 0-3: shore color
|
||||||
|
this.terrainParamsData[0] = shoreColor.r / 255;
|
||||||
|
this.terrainParamsData[1] = shoreColor.g / 255;
|
||||||
|
this.terrainParamsData[2] = shoreColor.b / 255;
|
||||||
|
this.terrainParamsData[3] = 1.0;
|
||||||
|
|
||||||
|
// Index 4-7: water base color
|
||||||
|
this.terrainParamsData[4] = waterColor.r / 255;
|
||||||
|
this.terrainParamsData[5] = waterColor.g / 255;
|
||||||
|
this.terrainParamsData[6] = waterColor.b / 255;
|
||||||
|
this.terrainParamsData[7] = 1.0;
|
||||||
|
|
||||||
|
// Index 8-11: shoreline water color
|
||||||
|
this.terrainParamsData[8] = shorelineWaterColor.r / 255;
|
||||||
|
this.terrainParamsData[9] = shorelineWaterColor.g / 255;
|
||||||
|
this.terrainParamsData[10] = shorelineWaterColor.b / 255;
|
||||||
|
this.terrainParamsData[11] = 1.0;
|
||||||
|
|
||||||
|
// Find a mountain tile at magnitude 20 (for accurate formula computation)
|
||||||
|
let mountainColor = { r: 230, g: 230, b: 230, a: 255 };
|
||||||
|
for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) {
|
||||||
|
if (
|
||||||
|
this.game.isLand(i) &&
|
||||||
|
!this.game.isShore(i) &&
|
||||||
|
this.game.magnitude(i) === 20
|
||||||
|
) {
|
||||||
|
const color = this.theme.terrainColor(this.game, i);
|
||||||
|
mountainColor = color.rgba;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no mag 20 found, try any mountain tile
|
||||||
|
if (mountainColor.r === 230 && mountainColor.g === 230) {
|
||||||
|
for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) {
|
||||||
|
if (
|
||||||
|
this.game.isLand(i) &&
|
||||||
|
!this.game.isShore(i) &&
|
||||||
|
this.game.magnitude(i) >= 20
|
||||||
|
) {
|
||||||
|
const color = this.theme.terrainColor(this.game, i);
|
||||||
|
mountainColor = color.rgba;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index 12-15: plains base color (magnitude 0)
|
||||||
|
this.terrainParamsData[12] = plainsColor.r / 255;
|
||||||
|
this.terrainParamsData[13] = plainsColor.g / 255;
|
||||||
|
this.terrainParamsData[14] = plainsColor.b / 255;
|
||||||
|
this.terrainParamsData[15] = 1.0;
|
||||||
|
|
||||||
|
// Index 16-19: highland base color (magnitude 10)
|
||||||
|
this.terrainParamsData[16] = highlandColor.r / 255;
|
||||||
|
this.terrainParamsData[17] = highlandColor.g / 255;
|
||||||
|
this.terrainParamsData[18] = highlandColor.b / 255;
|
||||||
|
this.terrainParamsData[19] = 1.0;
|
||||||
|
|
||||||
|
// Index 20-23: mountain base color (magnitude 20)
|
||||||
|
this.terrainParamsData[20] = mountainColor.r / 255;
|
||||||
|
this.terrainParamsData[21] = mountainColor.g / 255;
|
||||||
|
this.terrainParamsData[22] = mountainColor.b / 255;
|
||||||
|
this.terrainParamsData[23] = 1.0;
|
||||||
|
|
||||||
|
this.device.queue.writeBuffer(
|
||||||
|
this.terrainParamsBuffer,
|
||||||
|
0,
|
||||||
|
this.terrainParamsData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
markTerrainParamsDirty(): void {
|
||||||
|
this.needsTerrainParamsUpload = true;
|
||||||
|
}
|
||||||
|
|
||||||
uploadPalette(): boolean {
|
uploadPalette(): boolean {
|
||||||
if (!this.needsPaletteUpload) {
|
if (!this.needsPaletteUpload) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
struct TerrainParams {
|
||||||
|
shoreColor: vec4f, // Shore (land adjacent to water)
|
||||||
|
waterColor: vec4f, // Deep water base color
|
||||||
|
shorelineWaterColor: vec4f, // Water near shore
|
||||||
|
plainsBaseColor: vec4f, // Plains base RGB (magnitude 0)
|
||||||
|
highlandBaseColor: vec4f, // Highland base RGB (magnitude 10)
|
||||||
|
mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20)
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> params: TerrainParams;
|
||||||
|
@group(0) @binding(1) var terrainDataTex: texture_2d<u32>;
|
||||||
|
@group(0) @binding(2) var terrainTex: texture_storage_2d<rgba8unorm, write>;
|
||||||
|
|
||||||
|
// Terrain bit constants (matching GameMapImpl)
|
||||||
|
const IS_LAND_BIT: u32 = 7u;
|
||||||
|
const SHORELINE_BIT: u32 = 6u;
|
||||||
|
const OCEAN_BIT: u32 = 5u;
|
||||||
|
const MAGNITUDE_MASK: u32 = 0x1fu;
|
||||||
|
|
||||||
|
@compute @workgroup_size(8, 8)
|
||||||
|
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
||||||
|
let x = i32(globalId.x);
|
||||||
|
let y = i32(globalId.y);
|
||||||
|
let dims = textureDimensions(terrainDataTex);
|
||||||
|
|
||||||
|
if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let texCoord = vec2i(x, y);
|
||||||
|
let terrainData = textureLoad(terrainDataTex, texCoord, 0).x;
|
||||||
|
|
||||||
|
// Extract terrain bits
|
||||||
|
let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u;
|
||||||
|
let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u;
|
||||||
|
let isOcean = (terrainData & (1u << OCEAN_BIT)) != 0u;
|
||||||
|
let magnitude = terrainData & MAGNITUDE_MASK;
|
||||||
|
let mag = f32(magnitude);
|
||||||
|
|
||||||
|
var color: vec4f;
|
||||||
|
|
||||||
|
// Check if shore (land adjacent to water)
|
||||||
|
if (isLand && isShoreline) {
|
||||||
|
color = params.shoreColor;
|
||||||
|
} else if (!isLand) {
|
||||||
|
// Water tile
|
||||||
|
if (isShoreline) {
|
||||||
|
color = params.shorelineWaterColor;
|
||||||
|
} else {
|
||||||
|
// Deep water - color varies by magnitude
|
||||||
|
// CPU formula: waterColor - 10 + (11 - min(mag, 10))
|
||||||
|
// In normalized space: waterColor + (-10 + (11 - min(mag, 10))) / 255.0
|
||||||
|
// Simplified: waterColor + (1 - min(mag, 10)) / 255.0
|
||||||
|
let magClamped = min(mag, 10.0);
|
||||||
|
let adjustment = (1.0 - magClamped) / 255.0;
|
||||||
|
color = vec4f(
|
||||||
|
max(params.waterColor.r + adjustment, 0.0),
|
||||||
|
max(params.waterColor.g + adjustment, 0.0),
|
||||||
|
max(params.waterColor.b + adjustment, 0.0),
|
||||||
|
1.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Land tile - determine terrain type from magnitude
|
||||||
|
// CPU formulas:
|
||||||
|
// Plains: rgb(190, 220 - 2*mag, 138) for mag 0-9
|
||||||
|
// Highland: rgb(200 + 2*mag, 183 + 2*mag, 138 + 2*mag) for mag 10-19
|
||||||
|
// Mountain: rgb(230 + mag/2, 230 + mag/2, 230 + mag/2) for mag >= 20
|
||||||
|
//
|
||||||
|
// We sampled plains at mag 0, so plainsBaseColor = rgb(190, 220, 138) / 255
|
||||||
|
// We sampled highland at some mag 10-19, need to compute from mag 10
|
||||||
|
if (magnitude < 10u) {
|
||||||
|
// Plains: rgb(190, 220 - 2*mag, 138)
|
||||||
|
color = vec4f(
|
||||||
|
params.plainsBaseColor.r, // 190/255
|
||||||
|
max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0), // (220 - 2*mag)/255
|
||||||
|
params.plainsBaseColor.b, // 138/255
|
||||||
|
1.0
|
||||||
|
);
|
||||||
|
} else if (magnitude < 20u) {
|
||||||
|
// Highland: CPU formula is rgb(200 + 2*mag, 183 + 2*mag, 138 + 2*mag)
|
||||||
|
// We sampled highlandBaseColor at mag 10, so it's rgb(220, 203, 158) / 255
|
||||||
|
// For any mag 10-19: highlandBaseColor + 2*(mag - 10) / 255
|
||||||
|
let highlandMag = mag - 10.0;
|
||||||
|
color = vec4f(
|
||||||
|
min(params.highlandBaseColor.r + (2.0 * highlandMag) / 255.0, 1.0),
|
||||||
|
min(params.highlandBaseColor.g + (2.0 * highlandMag) / 255.0, 1.0),
|
||||||
|
min(params.highlandBaseColor.b + (2.0 * highlandMag) / 255.0, 1.0),
|
||||||
|
1.0
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Mountain: CPU formula is rgb(230 + mag/2, 230 + mag/2, 230 + mag/2)
|
||||||
|
// We sampled mountainBaseColor at mag 20, so it's rgb(240, 240, 240) / 255 for pastel
|
||||||
|
// For any mag >= 20: mountainBaseColor + (mag - 20) / 2 / 255
|
||||||
|
let mountainMag = mag - 20.0;
|
||||||
|
let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0);
|
||||||
|
color = vec4f(gray, gray, gray, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textureStore(terrainTex, texCoord, color);
|
||||||
|
}
|
||||||
@@ -1023,6 +1023,9 @@ export class GameImpl implements Game {
|
|||||||
tileStateView(): Uint16Array {
|
tileStateView(): Uint16Array {
|
||||||
return this._map.tileStateView();
|
return this._map.tileStateView();
|
||||||
}
|
}
|
||||||
|
terrainDataView(): Uint8Array {
|
||||||
|
return this._map.terrainDataView();
|
||||||
|
}
|
||||||
numTilesWithFallout(): number {
|
numTilesWithFallout(): number {
|
||||||
return this._map.numTilesWithFallout();
|
return this._map.numTilesWithFallout();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface GameMap {
|
|||||||
isDefended(ref: TileRef): boolean;
|
isDefended(ref: TileRef): boolean;
|
||||||
setDefended(ref: TileRef, value: boolean): void;
|
setDefended(ref: TileRef, value: boolean): void;
|
||||||
tileStateView(): Uint16Array;
|
tileStateView(): Uint16Array;
|
||||||
|
terrainDataView(): Uint8Array;
|
||||||
isOnEdgeOfMap(ref: TileRef): boolean;
|
isOnEdgeOfMap(ref: TileRef): boolean;
|
||||||
isBorder(ref: TileRef): boolean;
|
isBorder(ref: TileRef): boolean;
|
||||||
neighbors(ref: TileRef): TileRef[];
|
neighbors(ref: TileRef): TileRef[];
|
||||||
@@ -231,6 +232,10 @@ export class GameMapImpl implements GameMap {
|
|||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
terrainDataView(): Uint8Array {
|
||||||
|
return this.terrain;
|
||||||
|
}
|
||||||
|
|
||||||
isOnEdgeOfMap(ref: TileRef): boolean {
|
isOnEdgeOfMap(ref: TileRef): boolean {
|
||||||
const x = this.x(ref);
|
const x = this.x(ref);
|
||||||
const y = this.y(ref);
|
const y = this.y(ref);
|
||||||
|
|||||||
@@ -889,6 +889,9 @@ export class GameView implements GameMap {
|
|||||||
tileStateView(): Uint16Array {
|
tileStateView(): Uint16Array {
|
||||||
return this._map.tileStateView();
|
return this._map.tileStateView();
|
||||||
}
|
}
|
||||||
|
terrainDataView(): Uint8Array {
|
||||||
|
return this._map.terrainDataView();
|
||||||
|
}
|
||||||
isBorder(ref: TileRef): boolean {
|
isBorder(ref: TileRef): boolean {
|
||||||
return this._map.isBorder(ref);
|
return this._map.isBorder(ref);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user