From 256dac36fcd2d308ae825e1ba89c0a8d26c74c26 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:55:50 +0100 Subject: [PATCH] move terrain color computation to GPU compute shader --- src/client/graphics/layers/TerritoryLayer.ts | 9 + .../graphics/webgpu/TerritoryRenderer.ts | 89 ++++++- .../webgpu/compute/TerrainComputePass.ts | 127 ++++++++++ .../graphics/webgpu/core/GroundTruthData.ts | 225 +++++++++++++++++- .../shaders/compute/terrain-compute.wgsl | 102 ++++++++ src/core/game/GameImpl.ts | 3 + src/core/game/GameMap.ts | 5 + src/core/game/GameView.ts | 3 + 8 files changed, 559 insertions(+), 4 deletions(-) create mode 100644 src/client/graphics/webgpu/compute/TerrainComputePass.ts create mode 100644 src/client/graphics/webgpu/shaders/compute/terrain-compute.wgsl diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 7ef437b4d..632e96432 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -62,6 +62,7 @@ export class TerritoryLayer implements Layer { const currentTheme = this.game.config().theme(); if (currentTheme !== this.theme) { this.theme = currentTheme; + this.territoryRenderer?.refreshTerrain(); this.redraw(); } @@ -116,6 +117,14 @@ export class TerritoryLayer implements Layer { 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.updateHoverHighlight(); diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts index 171510e6f..7256f8765 100644 --- a/src/client/graphics/webgpu/TerritoryRenderer.ts +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -6,6 +6,7 @@ import { ComputePass } from "./compute/ComputePass"; import { DefendedClearPass } from "./compute/DefendedClearPass"; import { DefendedUpdatePass } from "./compute/DefendedUpdatePass"; import { StateUpdatePass } from "./compute/StateUpdatePass"; +import { TerrainComputePass } from "./compute/TerrainComputePass"; import { GroundTruthData } from "./core/GroundTruthData"; import { WebGPUDevice } from "./core/WebGPUDevice"; import { RenderPass } from "./render/RenderPass"; @@ -37,6 +38,7 @@ export class TerritoryRenderer { private renderPassOrder: RenderPass[] = []; // Pass instances + private terrainComputePass: TerrainComputePass | null = null; private stateUpdatePass: StateUpdatePass | null = null; private defendedClearPass: DefendedClearPass | null = null; private defendedUpdatePass: DefendedUpdatePass | null = null; @@ -99,15 +101,18 @@ export class TerritoryRenderer { state, ); - // Upload initial terrain texture - this.resources.uploadTerrain(); + // Upload terrain data and params (terrain colors will be computed on GPU) + 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.defendedClearPass = new DefendedClearPass(); this.defendedUpdatePass = new DefendedUpdatePass(); this.computePasses = [ + this.terrainComputePass, this.stateUpdatePass, this.defendedClearPass, 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. * Runs compute passes to update ground truth data. @@ -267,6 +316,9 @@ export class TerritoryRenderer { // Upload palette if needed 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) const wasDefensePostsDirty = (this.resources as any) .needsDefensePostsUpload; @@ -279,6 +331,9 @@ export class TerritoryRenderer { const numUpdates = this.stateUpdatePass ? ((this.stateUpdatePass as any).pendingTiles?.size ?? 0) : 0; + const needsTerrainCompute = this.terrainComputePass + ? this.terrainComputePass.needsUpdate() + : false; const range = this.game.config().defensePostRange(); const rangeChanged = range !== this.resources.getLastDefenseRange(); const countChanged = @@ -295,6 +350,7 @@ export class TerritoryRenderer { (hasPosts && numUpdates > 0); const needsCompute = + needsTerrainCompute === true || numUpdates > 0 || shouldRebuildDefended === true || this.needsDefendedHardClear === true; @@ -369,6 +425,33 @@ export class TerritoryRenderer { 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 textureView = this.device.context.getCurrentTexture().createView(); diff --git a/src/client/graphics/webgpu/compute/TerrainComputePass.ts b/src/client/graphics/webgpu/compute/TerrainComputePass.ts new file mode 100644 index 000000000..16f53a21a --- /dev/null +++ b/src/client/graphics/webgpu/compute/TerrainComputePass.ts @@ -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 { + 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; + } +} diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts index 8ea4c2624..6444791c0 100644 --- a/src/client/graphics/webgpu/core/GroundTruthData.ts +++ b/src/client/graphics/webgpu/core/GroundTruthData.ts @@ -20,12 +20,14 @@ export class GroundTruthData { // Textures public readonly stateTexture: GPUTexture; public readonly terrainTexture: GPUTexture; + public readonly terrainDataTexture: GPUTexture; public readonly paletteTexture: GPUTexture; public readonly defendedTexture: GPUTexture; // Buffers public readonly uniformBuffer: GPUBuffer; public readonly defenseParamsBuffer: GPUBuffer; + public readonly terrainParamsBuffer: GPUBuffer; public updatesBuffer: GPUBuffer | null = null; public defensePostsBuffer: GPUBuffer | null = null; @@ -41,8 +43,11 @@ export class GroundTruthData { private readonly mapWidth: number; private readonly mapHeight: number; private readonly state: Uint16Array; + private readonly terrainData: Uint8Array; private needsStateUpload = true; private needsPaletteUpload = true; + private needsTerrainDataUpload = true; + private needsTerrainParamsUpload = true; private paletteWidth = 1; private defensePostsCount = 0; private needsDefensePostsUpload = true; @@ -50,6 +55,7 @@ export class GroundTruthData { // Uniform data arrays private readonly uniformData = new Float32Array(12); 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) private viewWidth = 1; @@ -70,10 +76,12 @@ export class GroundTruthData { private readonly game: GameView, private readonly theme: Theme, state: Uint16Array, + terrainData: Uint8Array, mapWidth: number, mapHeight: number, ) { this.state = state; + this.terrainData = terrainData; this.mapWidth = mapWidth; this.mapHeight = mapHeight; @@ -97,6 +105,12 @@ export class GroundTruthData { 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) this.stateTexture = device.createTexture({ size: { width: mapWidth, height: mapHeight }, @@ -118,10 +132,17 @@ export class GroundTruthData { usage: COPY_DST_TEX | TEXTURE_BINDING, }); - // Terrain texture (rgba8unorm) + // Terrain texture (rgba8unorm) - output of terrain compute shader this.terrainTexture = device.createTexture({ size: { width: mapWidth, height: mapHeight }, 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, }); } @@ -137,6 +158,7 @@ export class GroundTruthData { game, theme, state, + game.terrainDataView(), game.width(), game.height(), ); @@ -212,6 +234,9 @@ export class GroundTruthData { } } + /** + * @deprecated Use terrain compute shader instead. This method is kept for fallback. + */ uploadTerrain(): void { const bytesPerRow = this.mapWidth * 4; 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 { if (!this.needsPaletteUpload) { return false; diff --git a/src/client/graphics/webgpu/shaders/compute/terrain-compute.wgsl b/src/client/graphics/webgpu/shaders/compute/terrain-compute.wgsl new file mode 100644 index 000000000..3cfaadeef --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/terrain-compute.wgsl @@ -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 params: TerrainParams; +@group(0) @binding(1) var terrainDataTex: texture_2d; +@group(0) @binding(2) var terrainTex: texture_storage_2d; + +// 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) { + 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); +} diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 9ba2c832e..4a1081ce1 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -995,6 +995,9 @@ export class GameImpl implements Game { tileStateView(): Uint16Array { return this._map.tileStateView(); } + terrainDataView(): Uint8Array { + return this._map.terrainDataView(); + } numTilesWithFallout(): number { return this._map.numTilesWithFallout(); } diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index dd00f3b22..e83f6edb2 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -30,6 +30,7 @@ export interface GameMap { isDefended(ref: TileRef): boolean; setDefended(ref: TileRef, value: boolean): void; tileStateView(): Uint16Array; + terrainDataView(): Uint8Array; isOnEdgeOfMap(ref: TileRef): boolean; isBorder(ref: TileRef): boolean; neighbors(ref: TileRef): TileRef[]; @@ -231,6 +232,10 @@ export class GameMapImpl implements GameMap { return this.state; } + terrainDataView(): Uint8Array { + return this.terrain; + } + isOnEdgeOfMap(ref: TileRef): boolean { const x = this.x(ref); const y = this.y(ref); diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 72d00d8af..2950fb8b1 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -889,6 +889,9 @@ export class GameView implements GameMap { tileStateView(): Uint16Array { return this._map.tileStateView(); } + terrainDataView(): Uint8Array { + return this._map.terrainDataView(); + } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); }