Add improved terrain compute shaders with lite and heavy variants

- Add terrain-compute-improved-lite.wgsl and terrain-compute-improved-heavy.wgsl
- Create TerrainShaderRegistry.ts for shader management
- Refactor TerrainComputePass to support dynamic shader switching
- Update TerritoryRenderer, TerritoryLayer, and GroundTruthData for new shader integration
- Enhance WebGPUDebugOverlay with additional debugging capabilities
This commit is contained in:
scamiv
2026-01-20 21:43:25 +01:00
parent fd87b0e3f8
commit c9ea04abac
9 changed files with 688 additions and 30 deletions
@@ -11,6 +11,10 @@ import {
} from "../../InputHandler";
import { FrameProfiler } from "../FrameProfiler";
import { TransformHandler } from "../TransformHandler";
import {
buildTerrainShaderParams,
readTerrainShaderId,
} from "../webgpu/render/TerrainShaderRegistry";
import {
buildTerritoryPostSmoothingParams,
readTerritoryPostSmoothingId,
@@ -43,6 +47,7 @@ export class TerritoryLayer implements Layer {
private lastPaletteSignature: string | null = null;
private lastDefensePostsSignature: string | null = null;
private lastTerrainShaderSignature: string | null = null;
private lastTerritoryShaderSignature: string | null = null;
private lastPreSmoothingSignature: string | null = null;
private lastPostSmoothingSignature: string | null = null;
@@ -87,6 +92,7 @@ export class TerritoryLayer implements Layer {
this.refreshPaletteIfNeeded();
this.refreshDefensePostsIfNeeded();
this.applyTerrainShaderSettings();
this.applyTerritoryShaderSettings();
this.applyTerritorySmoothingSettings();
@@ -124,6 +130,7 @@ export class TerritoryLayer implements Layer {
this.territoryRenderer = renderer;
this.territoryRenderer.setAlternativeView(this.alternativeView);
this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId);
this.applyTerrainShaderSettings(true);
this.applyTerritoryShaderSettings(true);
this.applyTerritorySmoothingSettings(true);
this.territoryRenderer.markAllDirty();
@@ -335,6 +342,25 @@ export class TerritoryLayer implements Layer {
this.territoryRenderer.setTerritoryShaderParams(params0, params1);
}
private applyTerrainShaderSettings(force: boolean = false) {
if (!this.territoryRenderer) {
return;
}
const terrainId = readTerrainShaderId(this.userSettings);
const { shaderPath, params0, params1 } = buildTerrainShaderParams(
this.userSettings,
terrainId,
);
const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`;
if (!force && signature === this.lastTerrainShaderSignature) {
return;
}
this.lastTerrainShaderSignature = signature;
this.territoryRenderer.setTerrainShader(shaderPath);
this.territoryRenderer.setTerrainShaderParams(params0, params1);
}
private applyTerritorySmoothingSettings(force: boolean = false) {
if (!this.territoryRenderer) {
return;
@@ -4,6 +4,13 @@ import { live } from "lit/directives/live.js";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import { WebGPUComputeMetricsEvent } from "../../InputHandler";
import {
TERRAIN_SHADER_KEY,
TERRAIN_SHADERS,
terrainShaderIdFromInt,
terrainShaderIntFromId,
TerrainShaderOption,
} from "../webgpu/render/TerrainShaderRegistry";
import {
TERRITORY_POST_SMOOTHING,
TERRITORY_POST_SMOOTHING_KEY,
@@ -21,9 +28,12 @@ import {
TERRITORY_SHADERS,
territoryShaderIdFromInt,
territoryShaderIntFromId,
TerritoryShaderOption,
} from "../webgpu/render/TerritoryShaderRegistry";
import { Layer } from "./Layer";
type ShaderOption = TerrainShaderOption | TerritoryShaderOption;
@customElement("webgpu-debug-overlay")
export class WebGPUDebugOverlay extends LitElement implements Layer {
@property({ type: Object })
@@ -186,6 +196,18 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
this.requestUpdate();
}
private selectedTerrainShaderId() {
const selected = this.userSettings.getInt(TERRAIN_SHADER_KEY, 0);
return terrainShaderIdFromInt(selected);
}
private setSelectedTerrainShaderId(
id: "classic" | "improved-lite" | "improved-heavy",
) {
this.userSettings.setInt(TERRAIN_SHADER_KEY, terrainShaderIntFromId(id));
this.requestUpdate();
}
private selectedPreSmoothingId() {
const selected = this.userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0);
return territoryPreSmoothingIdFromInt(selected);
@@ -212,9 +234,7 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
this.requestUpdate();
}
private renderOptionControl(
option: (typeof TERRITORY_SHADERS)[number]["options"][number],
) {
private renderOptionControl(option: ShaderOption) {
if (option.kind === "boolean") {
const enabled = this.userSettings.get(option.key, option.defaultValue);
return html`
@@ -289,6 +309,10 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
const shaderId = this.selectedShaderId();
const shader =
TERRITORY_SHADERS.find((s) => s.id === shaderId) ?? TERRITORY_SHADERS[0];
const terrainShaderId = this.selectedTerrainShaderId();
const terrainShader =
TERRAIN_SHADERS.find((s) => s.id === terrainShaderId) ??
TERRAIN_SHADERS[0];
const preId = this.selectedPreSmoothingId();
const pre =
TERRITORY_PRE_SMOOTHING.find((s) => s.id === preId) ??
@@ -315,6 +339,31 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
</div>
</div>
<div class="sectionTitle">Shaders</div>
<div class="row">
<div class="label">Terrain Shader</div>
<select
.value=${live(String(terrainShaderIntFromId(terrainShaderId)))}
@change=${(e: Event) => {
const raw = (e.target as HTMLSelectElement).value;
const next = terrainShaderIdFromInt(Number.parseInt(raw, 10));
this.setSelectedTerrainShaderId(next);
}}
>
${TERRAIN_SHADERS.map(
(s) =>
html`<option value=${String(terrainShaderIntFromId(s.id))}>
${s.label}
</option>`,
)}
</select>
</div>
${terrainShader.options.map((opt) => this.renderOptionControl(opt))}
<div class="sectionTitle">Territory</div>
<div class="row">
<div class="label">Territory Shader</div>
<select
@@ -33,6 +33,9 @@ export class TerritoryRenderer {
private territoryShaderPath = "render/territory.wgsl";
private territoryShaderParams0 = new Float32Array(4);
private territoryShaderParams1 = new Float32Array(4);
private terrainShaderPath = "compute/terrain-compute.wgsl";
private terrainShaderParams0 = new Float32Array(4);
private terrainShaderParams1 = new Float32Array(4);
private preSmoothingShaderPath = "compute/visual-state-smoothing.wgsl";
private preSmoothingParams0 = new Float32Array(4);
private postSmoothingShaderPath = "render/temporal-resolve.wgsl";
@@ -117,6 +120,10 @@ export class TerritoryRenderer {
this.territoryShaderParams0,
this.territoryShaderParams1,
);
this.resources.setTerrainShaderParams(
this.terrainShaderParams0,
this.terrainShaderParams1,
);
// Upload terrain data and params (terrain colors will be computed on GPU)
this.resources.uploadTerrainData();
@@ -124,6 +131,7 @@ export class TerritoryRenderer {
// Create compute passes (terrain compute should run first)
this.terrainComputePass = new TerrainComputePass();
void this.terrainComputePass.setShader(this.terrainShaderPath);
this.stateUpdatePass = new StateUpdatePass();
this.defendedStrengthFullPass = new DefendedStrengthFullPass();
this.defendedStrengthPass = new DefendedStrengthPass();
@@ -278,6 +286,16 @@ export class TerritoryRenderer {
this.resources?.invalidateHistory();
}
setTerrainShader(shaderPath: string): void {
this.terrainShaderPath = shaderPath;
if (!this.terrainComputePass) {
return;
}
void this.terrainComputePass.setShader(shaderPath).then(() => {
this.refreshTerrain();
});
}
setTerritoryShaderParams(
params0: Float32Array | number[],
params1: Float32Array | number[],
@@ -297,6 +315,25 @@ export class TerritoryRenderer {
this.resources.invalidateHistory();
}
setTerrainShaderParams(
params0: Float32Array | number[],
params1: Float32Array | number[],
): void {
for (let i = 0; i < 4; i++) {
this.terrainShaderParams0[i] = Number(params0[i] ?? 0);
this.terrainShaderParams1[i] = Number(params1[i] ?? 0);
}
if (!this.resources) {
return;
}
this.resources.setTerrainShaderParams(
this.terrainShaderParams0,
this.terrainShaderParams1,
);
this.refreshTerrain();
}
setPreSmoothing(
enabled: boolean,
shaderPath: string,
@@ -16,36 +16,28 @@ export class TerrainComputePass implements ComputePass {
private device: GPUDevice | null = null;
private resources: GroundTruthData | null = null;
private needsCompute = true;
private shaderPath = "compute/terrain-compute.wgsl";
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.ensureBindGroupLayout();
await this.setShader(this.shaderPath);
this.rebuildBindGroup();
}
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" },
},
],
});
async setShader(shaderPath: string): Promise<void> {
this.shaderPath = shaderPath;
if (!this.device || !this.bindGroupLayout) {
return;
}
this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
const shaderCode = await loadShader(shaderPath);
const shaderModule = this.device.createShaderModule({ code: shaderCode });
this.pipeline = this.device.createComputePipeline({
layout: this.device.createPipelineLayout({
bindGroupLayouts: [this.bindGroupLayout],
}),
compute: {
@@ -54,7 +46,7 @@ export class TerrainComputePass implements ComputePass {
},
});
this.rebuildBindGroup();
this.needsCompute = true;
}
needsUpdate(): boolean {
@@ -111,6 +103,32 @@ export class TerrainComputePass implements ComputePass {
});
}
private ensureBindGroupLayout(): void {
if (!this.device || this.bindGroupLayout) {
return;
}
this.bindGroupLayout = this.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" },
},
],
});
}
markDirty(): void {
this.needsCompute = true;
// Rebuild bind group in case terrain params buffer was recreated
@@ -86,7 +86,7 @@ export class GroundTruthData {
// Uniform data arrays
private readonly uniformData = new Float32Array(20);
private readonly temporalData = new Float32Array(8);
private readonly terrainParamsData = new Float32Array(24); // 6 vec4f: shore, water, shorelineWater, plainsBase, highlandBase, mountainBase
private readonly terrainParamsData = new Float32Array(32); // 8 vec4f: base colors + tuning
private readonly stateUpdateParamsData = new Uint32Array(4); // updateCount, range, pad, pad
private readonly defendedStrengthParamsData = new Uint32Array(4); // dirtyCount, range, pad, pad
@@ -101,6 +101,8 @@ export class GroundTruthData {
private territoryShaderParams0 = new Float32Array(4);
private territoryShaderParams1 = new Float32Array(4);
private terrainShaderParams0 = new Float32Array([0.0, 2.5, 0.6, 0.7]);
private terrainShaderParams1 = new Float32Array([0.0, 0.9, 0.6, 0.05]);
private paletteMaxSmallId = 0;
private ownerIndexWidth = 1;
@@ -153,9 +155,9 @@ export class GroundTruthData {
usage: UNIFORM | COPY_DST_BUF,
});
// Terrain params: 6x vec4f = 96 bytes (shore, water, shorelineWater, plainsBase, highlandBase, mountainBase)
// Terrain params: 8x vec4f = 128 bytes (base colors + tuning)
this.terrainParamsBuffer = device.createBuffer({
size: 96,
size: 128,
usage: UNIFORM | COPY_DST_BUF,
});
@@ -292,6 +294,17 @@ export class GroundTruthData {
}
}
setTerrainShaderParams(
params0: Float32Array | number[],
params1: Float32Array | number[],
): void {
for (let i = 0; i < 4; i++) {
this.terrainShaderParams0[i] = Number(params0[i] ?? 0);
this.terrainShaderParams1[i] = Number(params1[i] ?? 0);
}
this.needsTerrainParamsUpload = true;
}
setUseVisualStateTexture(enabled: boolean): void {
this.useVisualStateTexture = enabled;
if (enabled) {
@@ -674,6 +687,16 @@ export class GroundTruthData {
this.terrainParamsData[22] = mountainColor.b / 255;
this.terrainParamsData[23] = 1.0;
// Index 24-31: tuning params (shader-dependent)
this.terrainParamsData[24] = this.terrainShaderParams0[0];
this.terrainParamsData[25] = this.terrainShaderParams0[1];
this.terrainParamsData[26] = this.terrainShaderParams0[2];
this.terrainParamsData[27] = this.terrainShaderParams0[3];
this.terrainParamsData[28] = this.terrainShaderParams1[0];
this.terrainParamsData[29] = this.terrainShaderParams1[1];
this.terrainParamsData[30] = this.terrainShaderParams1[2];
this.terrainParamsData[31] = this.terrainShaderParams1[3];
this.device.queue.writeBuffer(
this.terrainParamsBuffer,
0,
@@ -0,0 +1,233 @@
export type TerrainShaderId = "classic" | "improved-lite" | "improved-heavy";
export type TerrainShaderOption =
| {
kind: "boolean";
key: string;
label: string;
defaultValue: boolean;
}
| {
kind: "range";
key: string;
label: string;
defaultValue: number;
min: number;
max: number;
step: number;
}
| {
kind: "enum";
key: string;
label: string;
defaultValue: number;
options: Array<{ value: number; label: string }>;
};
export interface TerrainShaderDefinition {
id: TerrainShaderId;
label: string;
wgslPath: string;
options: TerrainShaderOption[];
}
export const TERRAIN_SHADER_KEY = "settings.webgpu.terrain.shader";
export const TERRAIN_SHADERS: TerrainShaderDefinition[] = [
{
id: "classic",
label: "Classic",
wgslPath: "compute/terrain-compute.wgsl",
options: [],
},
{
id: "improved-lite",
label: "Improved (Lite)",
wgslPath: "compute/terrain-compute-improved-lite.wgsl",
options: [
{
kind: "range",
key: "settings.webgpu.terrain.improvedLite.noiseStrength",
label: "Noise Strength",
defaultValue: 0.025,
min: 0,
max: 0.08,
step: 0.005,
},
{
kind: "range",
key: "settings.webgpu.terrain.improvedLite.blendWidth",
label: "Biome Blend Width",
defaultValue: 2.5,
min: 0.5,
max: 5,
step: 0.25,
},
],
},
{
id: "improved-heavy",
label: "Improved (Heavy)",
wgslPath: "compute/terrain-compute-improved-heavy.wgsl",
options: [
{
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.noiseStrength",
label: "Noise Strength",
defaultValue: 0.025,
min: 0,
max: 0.1,
step: 0.005,
},
{
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.detailNoiseStrength",
label: "Detail Noise Strength",
defaultValue: 0.015,
min: 0,
max: 0.08,
step: 0.005,
},
{
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.blendWidth",
label: "Biome Blend Width",
defaultValue: 2.8,
min: 0.5,
max: 6,
step: 0.25,
},
{
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.lightingStrength",
label: "Lighting Strength",
defaultValue: 0.9,
min: 0,
max: 1,
step: 0.05,
},
{
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.cavityStrength",
label: "Cavity Strength",
defaultValue: 0.6,
min: 0,
max: 1,
step: 0.05,
},
],
},
];
export function getTerrainShaderById(
id: TerrainShaderId,
): TerrainShaderDefinition {
const found = TERRAIN_SHADERS.find((s) => s.id === id);
if (!found) {
throw new Error(`Unknown terrain shader: ${id}`);
}
return found;
}
export function terrainShaderIdFromInt(value: number): TerrainShaderId {
if (value === 1) return "improved-lite";
if (value === 2) return "improved-heavy";
return "classic";
}
export function terrainShaderIntFromId(id: TerrainShaderId): number {
if (id === "improved-lite") return 1;
if (id === "improved-heavy") return 2;
return 0;
}
export function readTerrainShaderId(userSettings: {
getInt: (key: string, defaultValue: number) => number;
}): TerrainShaderId {
return terrainShaderIdFromInt(userSettings.getInt(TERRAIN_SHADER_KEY, 0));
}
export function buildTerrainShaderParams(
userSettings: {
getFloat: (key: string, defaultValue: number) => number;
},
shaderId: TerrainShaderId,
): { shaderPath: string; params0: Float32Array; params1: Float32Array } {
const shorelineMixLand = 0.6;
const shorelineMixWater = 0.7;
const specularStrength = 0.05;
if (shaderId === "improved-lite") {
const noiseStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedLite.noiseStrength",
0.025,
);
const blendWidth = userSettings.getFloat(
"settings.webgpu.terrain.improvedLite.blendWidth",
2.5,
);
const params0 = new Float32Array([
noiseStrength,
blendWidth,
shorelineMixLand,
shorelineMixWater,
]);
const params1 = new Float32Array([0, 0, 0, specularStrength]);
return {
shaderPath: "compute/terrain-compute-improved-lite.wgsl",
params0,
params1,
};
}
if (shaderId === "improved-heavy") {
const noiseStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.noiseStrength",
0.025,
);
const detailNoiseStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.detailNoiseStrength",
0.015,
);
const blendWidth = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.blendWidth",
2.8,
);
const lightingStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.lightingStrength",
0.9,
);
const cavityStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.cavityStrength",
0.6,
);
const params0 = new Float32Array([
noiseStrength,
blendWidth,
shorelineMixLand,
shorelineMixWater,
]);
const params1 = new Float32Array([
detailNoiseStrength,
lightingStrength,
cavityStrength,
specularStrength,
]);
return {
shaderPath: "compute/terrain-compute-improved-heavy.wgsl",
params0,
params1,
};
}
const params0 = new Float32Array([
0,
2.5,
shorelineMixLand,
shorelineMixWater,
]);
const params1 = new Float32Array([0, 0, 0, specularStrength]);
return { shaderPath: "compute/terrain-compute.wgsl", params0, params1 };
}
@@ -0,0 +1,167 @@
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)
tuning0: vec4f, // x=noiseStrength, y=blendWidth, z=shoreMixLand, w=shoreMixWater
tuning1: vec4f, // x=detailNoise, y=lightingStrength, z=cavityStrength, w=specularStrength
};
@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 MAGNITUDE_MASK: u32 = 0x1fu;
fn hash21(p: vec2u) -> f32 {
var n = p.x * 0x9e3779b9u + p.y * 0x7f4a7c15u;
n ^= n >> 16u;
n *= 0x85ebca6bu;
n ^= n >> 13u;
n *= 0xc2b2ae35u;
n ^= n >> 16u;
return f32(n & 0x00ffffffu) / 16777215.0;
}
fn clampCoord(coord: vec2i, dims: vec2u) -> vec2i {
let maxX = i32(dims.x) - 1;
let maxY = i32(dims.y) - 1;
return vec2i(clamp(coord.x, 0, maxX), clamp(coord.y, 0, maxY));
}
fn sampleMagnitude(coord: vec2i, dims: vec2u) -> f32 {
let c = clampCoord(coord, dims);
let data = textureLoad(terrainDataTex, c, 0).x;
return f32(data & MAGNITUDE_MASK);
}
fn computeLandColor(
mag: f32,
noise: f32,
noiseStrength: f32,
blendWidth: f32,
) -> vec3f {
let plainsG = max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0);
let plains = vec3f(params.plainsBaseColor.r, plainsG, params.plainsBaseColor.b);
let highlandMag = clamp(mag - 10.0, 0.0, 9.0);
let highland = vec3f(
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),
);
let mountainMag = max(mag - 20.0, 0.0);
let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0);
let mountain = vec3f(gray, gray, gray);
let tHigh = smoothstep(10.0 - blendWidth, 10.0 + blendWidth, mag);
let tMount = smoothstep(20.0 - blendWidth, 20.0 + blendWidth, mag);
var land = mix(plains, highland, tHigh);
land = mix(land, mountain, tMount);
let noiseBias = (noise - 0.5) * noiseStrength;
return clamp(land + vec3f(noiseBias), vec3f(0.0), vec3f(1.0));
}
fn computeWaterColor(mag: f32, noise: f32, noiseStrength: f32) -> vec3f {
let depth = clamp(mag / 10.0, 0.0, 1.0);
var water = mix(params.shorelineWaterColor.rgb, params.waterColor.rgb, depth);
let noiseBias = (noise - 0.5) * noiseStrength;
water = clamp(water + vec3f(noiseBias), vec3f(0.0), vec3f(1.0));
return water;
}
@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;
let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u;
let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u;
let magnitude = terrainData & MAGNITUDE_MASK;
let mag = f32(magnitude);
let noise = hash21(vec2u(texCoord));
let noiseFine = hash21(vec2u(texCoord) * 3u + vec2u(17u, 29u));
let noiseStrength = max(params.tuning0.x, 0.0);
let blendWidth = max(params.tuning0.y, 0.1);
let shoreMixLand = clamp(params.tuning0.z, 0.0, 1.0);
let shoreMixWater = clamp(params.tuning0.w, 0.0, 1.0);
let detailNoiseStrength = max(params.tuning1.x, 0.0);
let lightingStrength = clamp(params.tuning1.y, 0.0, 1.0);
let cavityStrength = clamp(params.tuning1.z, 0.0, 1.0);
let specularStrength = max(params.tuning1.w, 0.0);
let hC = mag / 31.0;
let hL = sampleMagnitude(texCoord + vec2i(-1, 0), dims) / 31.0;
let hR = sampleMagnitude(texCoord + vec2i(1, 0), dims) / 31.0;
let hD = sampleMagnitude(texCoord + vec2i(0, -1), dims) / 31.0;
let hU = sampleMagnitude(texCoord + vec2i(0, 1), dims) / 31.0;
let dx = hR - hL;
let dy = hU - hD;
let normal = normalize(vec3f(-dx * 2.2, -dy * 2.2, 1.0));
let lightDir = normalize(vec3f(0.55, 0.45, 1.0));
let diffuse = clamp(dot(normal, lightDir), 0.0, 1.0);
let baseLighting = 0.55 + 0.45 * diffuse;
let lighting = mix(1.0, baseLighting, lightingStrength);
let slope = length(vec2f(dx, dy));
let rockiness = smoothstep(0.08, 0.28, slope);
let cavity = clamp(((hL + hR + hD + hU) * 0.25 - hC) * 2.0, 0.0, 0.25);
var color: vec4f;
if (isLand) {
var land = computeLandColor(mag, noise, noiseStrength, blendWidth);
if (isShoreline) {
land = mix(land, params.shoreColor.rgb, shoreMixLand);
}
land = mix(land, params.mountainBaseColor.rgb, rockiness * 0.6);
land = clamp(land * lighting, vec3f(0.0), vec3f(1.0));
land = clamp(land * (1.0 - cavity * cavityStrength), vec3f(0.0), vec3f(1.0));
land = clamp(
land + vec3f((noiseFine - 0.5) * detailNoiseStrength),
vec3f(0.0),
vec3f(1.0),
);
color = vec4f(land, 1.0);
} else {
var water = computeWaterColor(mag, noise, noiseStrength * 0.6);
if (isShoreline) {
water = mix(water, params.shorelineWaterColor.rgb, shoreMixWater);
}
let viewDir = vec3f(0.0, 0.0, 1.0);
let spec = pow(max(dot(reflect(-lightDir, normal), viewDir), 0.0), 24.0);
water = clamp(
water + vec3f(spec * specularStrength),
vec3f(0.0),
vec3f(1.0),
);
color = vec4f(water, 1.0);
}
textureStore(terrainTex, texCoord, color);
}
@@ -0,0 +1,103 @@
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)
tuning0: vec4f, // x=noiseStrength, y=blendWidth, z=shoreMixLand, w=shoreMixWater
tuning1: vec4f, // unused in lite
};
@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 MAGNITUDE_MASK: u32 = 0x1fu;
fn hash21(p: vec2u) -> f32 {
var n = p.x * 0x9e3779b9u + p.y * 0x7f4a7c15u;
n ^= n >> 16u;
n *= 0x85ebca6bu;
n ^= n >> 13u;
n *= 0xc2b2ae35u;
n ^= n >> 16u;
return f32(n & 0x00ffffffu) / 16777215.0;
}
fn computeLandColor(mag: f32, noise: f32, noiseStrength: f32, blendWidth: f32) -> vec3f {
let plainsG = max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0);
let plains = vec3f(params.plainsBaseColor.r, plainsG, params.plainsBaseColor.b);
let highlandMag = clamp(mag - 10.0, 0.0, 9.0);
let highland = vec3f(
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),
);
let mountainMag = max(mag - 20.0, 0.0);
let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0);
let mountain = vec3f(gray, gray, gray);
let tHigh = smoothstep(10.0 - blendWidth, 10.0 + blendWidth, mag);
let tMount = smoothstep(20.0 - blendWidth, 20.0 + blendWidth, mag);
var land = mix(plains, highland, tHigh);
land = mix(land, mountain, tMount);
let noiseBias = (noise - 0.5) * noiseStrength;
return clamp(land + vec3f(noiseBias), vec3f(0.0), vec3f(1.0));
}
fn computeWaterColor(mag: f32, noise: f32, noiseStrength: f32) -> vec3f {
let depth = clamp(mag / 10.0, 0.0, 1.0);
var water = mix(params.shorelineWaterColor.rgb, params.waterColor.rgb, depth);
let noiseBias = (noise - 0.5) * noiseStrength;
water = clamp(water + vec3f(noiseBias), vec3f(0.0), vec3f(1.0));
return water;
}
@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;
let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u;
let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u;
let magnitude = terrainData & MAGNITUDE_MASK;
let mag = f32(magnitude);
let noise = hash21(vec2u(texCoord));
let noiseStrength = max(params.tuning0.x, 0.0);
let blendWidth = max(params.tuning0.y, 0.1);
let shoreMixLand = clamp(params.tuning0.z, 0.0, 1.0);
let shoreMixWater = clamp(params.tuning0.w, 0.0, 1.0);
var color: vec4f;
if (isLand) {
var land = computeLandColor(mag, noise, noiseStrength, blendWidth);
if (isShoreline) {
land = mix(land, params.shoreColor.rgb, shoreMixLand);
}
color = vec4f(land, 1.0);
} else {
var water = computeWaterColor(mag, noise, noiseStrength * 0.6);
if (isShoreline) {
water = mix(water, params.shorelineWaterColor.rgb, shoreMixWater);
}
color = vec4f(water, 1.0);
}
textureStore(terrainTex, texCoord, color);
}
@@ -5,6 +5,8 @@ struct TerrainParams {
plainsBaseColor: vec4f, // Plains base RGB (magnitude 0)
highlandBaseColor: vec4f, // Highland base RGB (magnitude 10)
mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20)
tuning0: vec4f, // Shader tuning params (unused in classic)
tuning1: vec4f, // Shader tuning params (unused in classic)
};
@group(0) @binding(0) var<uniform> params: TerrainParams;