mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
add temporal smoothing for territory rendering
Add user-selectable temporal smoothing pipeline to create smooth visual transitions between simulation ticks (~10Hz) and display frames (~60Hz). Pre-render smoothing provides sharp tile dissolve transitions using compute shaders, while post-render smoothing blends frames for fluid animation. Includes tick timing with exponential moving averages for stable temporal parameters. New Components: - TerritoryPreSmoothingRegistry & TerritoryPostSmoothingRegistry for mode selection - VisualStateSmoothingPass compute shader for pre-render dissolve effects - TemporalResolvePass render shader for post-render temporal compositing - Enhanced GroundTruthData with temporal uniforms and history textures Performance: No full-map per-frame compute, single fullscreen post-render pass. Compatible with all territory shaders (classic, retro, future variants). Files: 12 files changed, 1357 insertions(+), 8 deletions(-)
This commit is contained in:
@@ -11,6 +11,14 @@ import {
|
||||
} from "../../InputHandler";
|
||||
import { FrameProfiler } from "../FrameProfiler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import {
|
||||
buildTerritoryPostSmoothingParams,
|
||||
readTerritoryPostSmoothingId,
|
||||
} from "../webgpu/render/TerritoryPostSmoothingRegistry";
|
||||
import {
|
||||
buildTerritoryPreSmoothingParams,
|
||||
readTerritoryPreSmoothingId,
|
||||
} from "../webgpu/render/TerritoryPreSmoothingRegistry";
|
||||
import {
|
||||
buildTerritoryShaderParams,
|
||||
readTerritoryShaderId,
|
||||
@@ -36,6 +44,8 @@ export class TerritoryLayer implements Layer {
|
||||
private lastPaletteSignature: string | null = null;
|
||||
private lastDefensePostsSignature: string | null = null;
|
||||
private lastTerritoryShaderSignature: string | null = null;
|
||||
private lastPreSmoothingSignature: string | null = null;
|
||||
private lastPostSmoothingSignature: string | null = null;
|
||||
|
||||
private lastMousePosition: { x: number; y: number } | null = null;
|
||||
private hoveredOwnerSmallId: number | null = null;
|
||||
@@ -78,6 +88,7 @@ export class TerritoryLayer implements Layer {
|
||||
this.refreshPaletteIfNeeded();
|
||||
this.refreshDefensePostsIfNeeded();
|
||||
this.applyTerritoryShaderSettings();
|
||||
this.applyTerritorySmoothingSettings();
|
||||
|
||||
const updatedTiles = this.game.recentlyUpdatedTiles();
|
||||
for (let i = 0; i < updatedTiles.length; i++) {
|
||||
@@ -114,6 +125,7 @@ export class TerritoryLayer implements Layer {
|
||||
this.territoryRenderer.setAlternativeView(this.alternativeView);
|
||||
this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId);
|
||||
this.applyTerritoryShaderSettings(true);
|
||||
this.applyTerritorySmoothingSettings(true);
|
||||
this.territoryRenderer.markAllDirty();
|
||||
this.territoryRenderer.refreshPalette();
|
||||
this.lastPaletteSignature = this.computePaletteSignature();
|
||||
@@ -143,6 +155,7 @@ export class TerritoryLayer implements Layer {
|
||||
|
||||
// Apply user settings even while the game is paused (settings modal).
|
||||
this.applyTerritoryShaderSettings();
|
||||
this.applyTerritorySmoothingSettings();
|
||||
|
||||
this.ensureTerritoryCanvasAttached(context.canvas);
|
||||
this.updateHoverHighlight();
|
||||
@@ -322,6 +335,42 @@ export class TerritoryLayer implements Layer {
|
||||
this.territoryRenderer.setTerritoryShaderParams(params0, params1);
|
||||
}
|
||||
|
||||
private applyTerritorySmoothingSettings(force: boolean = false) {
|
||||
if (!this.territoryRenderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preId = readTerritoryPreSmoothingId(this.userSettings);
|
||||
const preParams = buildTerritoryPreSmoothingParams(
|
||||
this.userSettings,
|
||||
preId,
|
||||
);
|
||||
const preSignature = `${preId}:${Array.from(preParams.params0).join(",")}`;
|
||||
if (force || preSignature !== this.lastPreSmoothingSignature) {
|
||||
this.lastPreSmoothingSignature = preSignature;
|
||||
this.territoryRenderer.setPreSmoothing(
|
||||
preParams.enabled,
|
||||
preParams.shaderPath,
|
||||
preParams.params0,
|
||||
);
|
||||
}
|
||||
|
||||
const postId = readTerritoryPostSmoothingId(this.userSettings);
|
||||
const postParams = buildTerritoryPostSmoothingParams(
|
||||
this.userSettings,
|
||||
postId,
|
||||
);
|
||||
const postSignature = `${postId}:${Array.from(postParams.params0).join(",")}`;
|
||||
if (force || postSignature !== this.lastPostSmoothingSignature) {
|
||||
this.lastPostSmoothingSignature = postSignature;
|
||||
this.territoryRenderer.setPostSmoothing(
|
||||
postParams.enabled,
|
||||
postParams.shaderPath,
|
||||
postParams.params0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private computeDefensePostsSignature(): string {
|
||||
// Active + completed posts only.
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -4,6 +4,18 @@ import { live } from "lit/directives/live.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { WebGPUComputeMetricsEvent } from "../../InputHandler";
|
||||
import {
|
||||
TERRITORY_POST_SMOOTHING,
|
||||
TERRITORY_POST_SMOOTHING_KEY,
|
||||
territoryPostSmoothingIdFromInt,
|
||||
territoryPostSmoothingIntFromId,
|
||||
} from "../webgpu/render/TerritoryPostSmoothingRegistry";
|
||||
import {
|
||||
TERRITORY_PRE_SMOOTHING,
|
||||
TERRITORY_PRE_SMOOTHING_KEY,
|
||||
territoryPreSmoothingIdFromInt,
|
||||
territoryPreSmoothingIntFromId,
|
||||
} from "../webgpu/render/TerritoryPreSmoothingRegistry";
|
||||
import {
|
||||
TERRITORY_SHADER_KEY,
|
||||
TERRITORY_SHADERS,
|
||||
@@ -89,6 +101,15 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin-top: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type="range"] {
|
||||
width: 170px;
|
||||
@@ -165,6 +186,32 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private selectedPreSmoothingId() {
|
||||
const selected = this.userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0);
|
||||
return territoryPreSmoothingIdFromInt(selected);
|
||||
}
|
||||
|
||||
private setSelectedPreSmoothingId(id: "off" | "dissolve" | "budget") {
|
||||
this.userSettings.setInt(
|
||||
TERRITORY_PRE_SMOOTHING_KEY,
|
||||
territoryPreSmoothingIntFromId(id),
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private selectedPostSmoothingId() {
|
||||
const selected = this.userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0);
|
||||
return territoryPostSmoothingIdFromInt(selected);
|
||||
}
|
||||
|
||||
private setSelectedPostSmoothingId(id: "off" | "fade" | "dissolve") {
|
||||
this.userSettings.setInt(
|
||||
TERRITORY_POST_SMOOTHING_KEY,
|
||||
territoryPostSmoothingIntFromId(id),
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private renderOptionControl(
|
||||
option: (typeof TERRITORY_SHADERS)[number]["options"][number],
|
||||
) {
|
||||
@@ -242,6 +289,14 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
|
||||
const shaderId = this.selectedShaderId();
|
||||
const shader =
|
||||
TERRITORY_SHADERS.find((s) => s.id === shaderId) ?? TERRITORY_SHADERS[0];
|
||||
const preId = this.selectedPreSmoothingId();
|
||||
const pre =
|
||||
TERRITORY_PRE_SMOOTHING.find((s) => s.id === preId) ??
|
||||
TERRITORY_PRE_SMOOTHING[0];
|
||||
const postId = this.selectedPostSmoothingId();
|
||||
const post =
|
||||
TERRITORY_POST_SMOOTHING.find((s) => s.id === postId) ??
|
||||
TERRITORY_POST_SMOOTHING[0];
|
||||
|
||||
return html`
|
||||
<div class="overlay">
|
||||
@@ -280,6 +335,58 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
|
||||
</div>
|
||||
|
||||
${shader.options.map((opt) => this.renderOptionControl(opt))}
|
||||
|
||||
<div class="sectionTitle">Temporal</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="label">Post Compute</div>
|
||||
<select
|
||||
.value=${live(String(territoryPreSmoothingIntFromId(preId)))}
|
||||
@change=${(e: Event) => {
|
||||
const raw = (e.target as HTMLSelectElement).value;
|
||||
const next = territoryPreSmoothingIdFromInt(
|
||||
Number.parseInt(raw, 10),
|
||||
);
|
||||
this.setSelectedPreSmoothingId(next);
|
||||
}}
|
||||
>
|
||||
${TERRITORY_PRE_SMOOTHING.map(
|
||||
(s) =>
|
||||
html`<option
|
||||
value=${String(territoryPreSmoothingIntFromId(s.id))}
|
||||
>
|
||||
${s.label}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
${pre.options.map((opt) => this.renderOptionControl(opt))}
|
||||
|
||||
<div class="row">
|
||||
<div class="label">Post Render</div>
|
||||
<select
|
||||
.value=${live(String(territoryPostSmoothingIntFromId(postId)))}
|
||||
@change=${(e: Event) => {
|
||||
const raw = (e.target as HTMLSelectElement).value;
|
||||
const next = territoryPostSmoothingIdFromInt(
|
||||
Number.parseInt(raw, 10),
|
||||
);
|
||||
this.setSelectedPostSmoothingId(next);
|
||||
}}
|
||||
>
|
||||
${TERRITORY_POST_SMOOTHING.map(
|
||||
(s) =>
|
||||
html`<option
|
||||
value=${String(territoryPostSmoothingIntFromId(s.id))}
|
||||
>
|
||||
${s.label}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
${post.options.map((opt) => this.renderOptionControl(opt))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import { DefendedStrengthFullPass } from "./compute/DefendedStrengthFullPass";
|
||||
import { DefendedStrengthPass } from "./compute/DefendedStrengthPass";
|
||||
import { StateUpdatePass } from "./compute/StateUpdatePass";
|
||||
import { TerrainComputePass } from "./compute/TerrainComputePass";
|
||||
import { VisualStateSmoothingPass } from "./compute/VisualStateSmoothingPass";
|
||||
import { GroundTruthData } from "./core/GroundTruthData";
|
||||
import { WebGPUDevice } from "./core/WebGPUDevice";
|
||||
import { RenderPass } from "./render/RenderPass";
|
||||
import { TemporalResolvePass } from "./render/TemporalResolvePass";
|
||||
import { TerritoryRenderPass } from "./render/TerritoryRenderPass";
|
||||
|
||||
export interface TerritoryWebGLCreateResult {
|
||||
@@ -31,10 +33,15 @@ export class TerritoryRenderer {
|
||||
private territoryShaderPath = "render/territory.wgsl";
|
||||
private territoryShaderParams0 = new Float32Array(4);
|
||||
private territoryShaderParams1 = new Float32Array(4);
|
||||
private preSmoothingShaderPath = "compute/visual-state-smoothing.wgsl";
|
||||
private preSmoothingParams0 = new Float32Array(4);
|
||||
private postSmoothingShaderPath = "render/temporal-resolve.wgsl";
|
||||
private postSmoothingParams0 = new Float32Array(4);
|
||||
|
||||
// Compute passes
|
||||
private computePasses: ComputePass[] = [];
|
||||
private computePassOrder: ComputePass[] = [];
|
||||
private frameComputePasses: ComputePass[] = [];
|
||||
|
||||
// Render passes
|
||||
private renderPasses: RenderPass[] = [];
|
||||
@@ -45,9 +52,14 @@ export class TerritoryRenderer {
|
||||
private stateUpdatePass: StateUpdatePass | null = null;
|
||||
private defendedStrengthFullPass: DefendedStrengthFullPass | null = null;
|
||||
private defendedStrengthPass: DefendedStrengthPass | null = null;
|
||||
private visualStateSmoothingPass: VisualStateSmoothingPass | null = null;
|
||||
private territoryRenderPass: TerritoryRenderPass | null = null;
|
||||
private temporalResolvePass: TemporalResolvePass | null = null;
|
||||
private readonly defensePostRange: number;
|
||||
|
||||
private preSmoothingEnabled = false;
|
||||
private postSmoothingEnabled = false;
|
||||
|
||||
private constructor(
|
||||
private readonly game: GameView,
|
||||
private readonly theme: Theme,
|
||||
@@ -115,6 +127,7 @@ export class TerritoryRenderer {
|
||||
this.stateUpdatePass = new StateUpdatePass();
|
||||
this.defendedStrengthFullPass = new DefendedStrengthFullPass();
|
||||
this.defendedStrengthPass = new DefendedStrengthPass();
|
||||
this.visualStateSmoothingPass = new VisualStateSmoothingPass();
|
||||
|
||||
this.computePasses = [
|
||||
this.terrainComputePass,
|
||||
@@ -123,15 +136,22 @@ export class TerritoryRenderer {
|
||||
this.defendedStrengthPass,
|
||||
];
|
||||
|
||||
this.frameComputePasses = [this.visualStateSmoothingPass];
|
||||
|
||||
// Create render passes
|
||||
this.territoryRenderPass = new TerritoryRenderPass();
|
||||
this.renderPasses = [this.territoryRenderPass];
|
||||
this.temporalResolvePass = new TemporalResolvePass();
|
||||
this.renderPasses = [this.territoryRenderPass, this.temporalResolvePass];
|
||||
|
||||
// Initialize all passes
|
||||
for (const pass of this.computePasses) {
|
||||
await pass.init(webgpuDevice.device, this.resources);
|
||||
}
|
||||
|
||||
for (const pass of this.frameComputePasses) {
|
||||
await pass.init(webgpuDevice.device, this.resources);
|
||||
}
|
||||
|
||||
for (const pass of this.renderPasses) {
|
||||
await pass.init(
|
||||
webgpuDevice.device,
|
||||
@@ -144,6 +164,9 @@ export class TerritoryRenderer {
|
||||
await this.territoryRenderPass.setShader(this.territoryShaderPath);
|
||||
}
|
||||
|
||||
this.applyPreSmoothingConfig();
|
||||
this.applyPostSmoothingConfig();
|
||||
|
||||
// Compute dependency order (topological sort)
|
||||
this.computePassOrder = this.topologicalSort(this.computePasses);
|
||||
this.renderPassOrder = this.topologicalSort(this.renderPasses);
|
||||
@@ -215,6 +238,15 @@ export class TerritoryRenderer {
|
||||
this.canvas.height = nextHeight;
|
||||
this.resources.setViewSize(nextWidth, nextHeight);
|
||||
this.device.reconfigure();
|
||||
|
||||
if (this.postSmoothingEnabled && this.resources) {
|
||||
this.resources.ensurePostSmoothingTextures(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
this.device.canvasFormat,
|
||||
);
|
||||
this.resources.invalidateHistory();
|
||||
}
|
||||
}
|
||||
|
||||
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
|
||||
@@ -243,6 +275,7 @@ export class TerritoryRenderer {
|
||||
if (this.territoryRenderPass) {
|
||||
void this.territoryRenderPass.setShader(shaderPath);
|
||||
}
|
||||
this.resources?.invalidateHistory();
|
||||
}
|
||||
|
||||
setTerritoryShaderParams(
|
||||
@@ -261,6 +294,77 @@ export class TerritoryRenderer {
|
||||
this.territoryShaderParams0,
|
||||
this.territoryShaderParams1,
|
||||
);
|
||||
this.resources.invalidateHistory();
|
||||
}
|
||||
|
||||
setPreSmoothing(
|
||||
enabled: boolean,
|
||||
shaderPath: string,
|
||||
params0: Float32Array | number[],
|
||||
): void {
|
||||
this.preSmoothingEnabled = enabled;
|
||||
if (shaderPath) {
|
||||
this.preSmoothingShaderPath = shaderPath;
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
this.preSmoothingParams0[i] = Number(params0[i] ?? 0);
|
||||
}
|
||||
this.applyPreSmoothingConfig();
|
||||
}
|
||||
|
||||
setPostSmoothing(
|
||||
enabled: boolean,
|
||||
shaderPath: string,
|
||||
params0: Float32Array | number[],
|
||||
): void {
|
||||
this.postSmoothingEnabled = enabled;
|
||||
if (shaderPath) {
|
||||
this.postSmoothingShaderPath = shaderPath;
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
this.postSmoothingParams0[i] = Number(params0[i] ?? 0);
|
||||
}
|
||||
this.applyPostSmoothingConfig();
|
||||
}
|
||||
|
||||
private applyPreSmoothingConfig(): void {
|
||||
if (!this.resources || !this.visualStateSmoothingPass) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resources.setUseVisualStateTexture(this.preSmoothingEnabled);
|
||||
if (this.preSmoothingEnabled) {
|
||||
this.resources.ensureVisualStateTexture();
|
||||
void this.visualStateSmoothingPass.setShader(this.preSmoothingShaderPath);
|
||||
this.visualStateSmoothingPass.setParams(this.preSmoothingParams0);
|
||||
} else {
|
||||
this.visualStateSmoothingPass.setParams(new Float32Array(4));
|
||||
this.resources.releaseVisualStateTexture();
|
||||
}
|
||||
|
||||
this.resources.invalidateHistory();
|
||||
}
|
||||
|
||||
private applyPostSmoothingConfig(): void {
|
||||
if (!this.resources || !this.temporalResolvePass || !this.device) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.postSmoothingEnabled) {
|
||||
void this.temporalResolvePass.setShader(this.postSmoothingShaderPath);
|
||||
this.temporalResolvePass.setParams(this.postSmoothingParams0);
|
||||
this.temporalResolvePass.setEnabled(true);
|
||||
this.resources.ensurePostSmoothingTextures(
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
this.device.canvasFormat,
|
||||
);
|
||||
} else {
|
||||
this.temporalResolvePass.setEnabled(false);
|
||||
this.resources.releasePostSmoothingTextures();
|
||||
}
|
||||
|
||||
this.resources.invalidateHistory();
|
||||
}
|
||||
|
||||
markTile(tile: TileRef): void {
|
||||
@@ -340,6 +444,8 @@ export class TerritoryRenderer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resources.updateTickTiming(performance.now() / 1000);
|
||||
|
||||
if (this.game.config().defensePostRange() !== this.defensePostRange) {
|
||||
throw new Error("defensePostRange changed at runtime; unsupported.");
|
||||
}
|
||||
@@ -356,9 +462,14 @@ export class TerritoryRenderer {
|
||||
// Initial state upload
|
||||
this.resources.uploadState();
|
||||
|
||||
const stateUpdatesPending = this.stateUpdatePass?.needsUpdate() ?? false;
|
||||
if (!stateUpdatesPending) {
|
||||
this.resources.setLastStateUpdateCount(0);
|
||||
}
|
||||
|
||||
const needsCompute =
|
||||
(this.terrainComputePass?.needsUpdate() ?? false) ||
|
||||
(this.stateUpdatePass?.needsUpdate() ?? false) ||
|
||||
stateUpdatesPending ||
|
||||
(this.defendedStrengthFullPass?.needsUpdate() ?? false) ||
|
||||
(this.defendedStrengthPass?.needsUpdate() ?? false);
|
||||
|
||||
@@ -368,6 +479,23 @@ export class TerritoryRenderer {
|
||||
|
||||
const encoder = this.device.device.createCommandEncoder();
|
||||
|
||||
if (this.preSmoothingEnabled && stateUpdatesPending) {
|
||||
this.resources.ensureVisualStateTexture();
|
||||
const visualStateTexture = this.resources.getVisualStateTexture();
|
||||
if (visualStateTexture) {
|
||||
encoder.copyTextureToTexture(
|
||||
{ texture: this.resources.stateTexture },
|
||||
{ texture: visualStateTexture },
|
||||
{
|
||||
width: this.resources.getMapWidth(),
|
||||
height: this.resources.getMapHeight(),
|
||||
depthOrArrayLayers: 1,
|
||||
},
|
||||
);
|
||||
this.resources.consumeVisualStateSyncNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
// Execute compute passes in dependency order (clear will run before update if needed)
|
||||
for (const pass of this.computePassOrder) {
|
||||
if (!pass.needsUpdate()) {
|
||||
@@ -393,6 +521,9 @@ export class TerritoryRenderer {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowSec = performance.now() / 1000;
|
||||
this.resources.writeTemporalUniformBuffer(nowSec);
|
||||
|
||||
// If terrain needs recomputation, trigger it asynchronously (no blocking)
|
||||
// It will be ready for the next frame, acceptable trade-off for performance
|
||||
if (this.terrainComputePass?.needsUpdate()) {
|
||||
@@ -404,14 +535,54 @@ export class TerritoryRenderer {
|
||||
}
|
||||
|
||||
const encoder = this.device.device.createCommandEncoder();
|
||||
const textureView = this.device.context.getCurrentTexture().createView();
|
||||
const swapchainView = this.device.context.getCurrentTexture().createView();
|
||||
|
||||
if (
|
||||
this.preSmoothingEnabled &&
|
||||
this.resources.consumeVisualStateSyncNeeded()
|
||||
) {
|
||||
const visualStateTexture = this.resources.getVisualStateTexture();
|
||||
if (visualStateTexture) {
|
||||
encoder.copyTextureToTexture(
|
||||
{ texture: this.resources.stateTexture },
|
||||
{ texture: visualStateTexture },
|
||||
{
|
||||
width: this.resources.getMapWidth(),
|
||||
height: this.resources.getMapHeight(),
|
||||
depthOrArrayLayers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pass of this.frameComputePasses) {
|
||||
if (!pass.needsUpdate()) {
|
||||
continue;
|
||||
}
|
||||
pass.execute(encoder, this.resources);
|
||||
}
|
||||
|
||||
// Execute render passes in dependency order
|
||||
for (const pass of this.renderPassOrder) {
|
||||
if (!pass.needsUpdate()) {
|
||||
continue;
|
||||
}
|
||||
pass.execute(encoder, this.resources, textureView);
|
||||
if (pass === this.territoryRenderPass && this.postSmoothingEnabled) {
|
||||
if (!this.resources.getCurrentColorTexture()) {
|
||||
this.resources.ensurePostSmoothingTextures(
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
this.device.canvasFormat,
|
||||
);
|
||||
}
|
||||
const currentTexture = this.resources.getCurrentColorTexture();
|
||||
if (currentTexture) {
|
||||
pass.execute(encoder, this.resources, currentTexture.createView());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
pass.execute(encoder, this.resources, swapchainView);
|
||||
}
|
||||
|
||||
this.device.device.queue.submit([encoder.finish()]);
|
||||
|
||||
@@ -87,6 +87,8 @@ export class StateUpdatePass implements ComputePass {
|
||||
return;
|
||||
}
|
||||
|
||||
resources.setLastStateUpdateCount(numUpdates);
|
||||
|
||||
const updatesBuffer = resources.ensureUpdatesBuffer(numUpdates);
|
||||
resources.writeStateUpdateParamsBuffer(numUpdates);
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { GroundTruthData } from "../core/GroundTruthData";
|
||||
import { loadShader } from "../core/ShaderLoader";
|
||||
import { ComputePass } from "./ComputePass";
|
||||
|
||||
/**
|
||||
* Per-frame compute pass that updates the visual state texture.
|
||||
* Supports dissolve and budgeted reveal modes.
|
||||
*/
|
||||
export class VisualStateSmoothingPass implements ComputePass {
|
||||
name = "visual-state-smoothing";
|
||||
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 paramsBuffer: GPUBuffer | null = null;
|
||||
private paramsData = new Float32Array(8);
|
||||
private enabled = false;
|
||||
private shaderPath = "compute/visual-state-smoothing.wgsl";
|
||||
private mode = 0;
|
||||
private curveExp = 1;
|
||||
private boundUpdatesBuffer: GPUBuffer | null = null;
|
||||
private boundVisualStateTexture: GPUTexture | null = null;
|
||||
|
||||
async init(device: GPUDevice, resources: GroundTruthData): Promise<void> {
|
||||
this.device = device;
|
||||
this.resources = resources;
|
||||
|
||||
const GPUBufferUsage = (globalThis as any).GPUBufferUsage;
|
||||
const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40;
|
||||
const COPY_DST = GPUBufferUsage?.COPY_DST ?? 0x8;
|
||||
|
||||
this.paramsBuffer = device.createBuffer({
|
||||
size: 32,
|
||||
usage: UNIFORM | COPY_DST,
|
||||
});
|
||||
|
||||
await this.setShader(this.shaderPath);
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
|
||||
async setShader(shaderPath: string): Promise<void> {
|
||||
this.shaderPath = shaderPath;
|
||||
if (!this.device) {
|
||||
return;
|
||||
}
|
||||
const shaderCode = await loadShader(shaderPath);
|
||||
const shaderModule = this.device.createShaderModule({ code: shaderCode });
|
||||
|
||||
this.bindGroupLayout = this.device.createBindGroupLayout({
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "read-only-storage" },
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
storageTexture: { format: "r32uint" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.pipeline = this.device.createComputePipeline({
|
||||
layout: this.device.createPipelineLayout({
|
||||
bindGroupLayouts: [this.bindGroupLayout],
|
||||
}),
|
||||
compute: {
|
||||
module: shaderModule,
|
||||
entryPoint: "main",
|
||||
},
|
||||
});
|
||||
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
|
||||
setParams(params0: Float32Array | number[]): void {
|
||||
this.mode = Number(params0[0] ?? 0);
|
||||
this.curveExp = Number(params0[1] ?? 1);
|
||||
this.enabled = this.mode > 0;
|
||||
}
|
||||
|
||||
needsUpdate(): boolean {
|
||||
if (!this.enabled || !this.resources) {
|
||||
return false;
|
||||
}
|
||||
return this.resources.getLastStateUpdateCount() > 0;
|
||||
}
|
||||
|
||||
execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void {
|
||||
if (!this.device || !this.pipeline || !this.paramsBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateCount = resources.getLastStateUpdateCount();
|
||||
if (updateCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatesBuffer = resources.updatesBuffer;
|
||||
const visualStateTexture = resources.getVisualStateTexture();
|
||||
if (!updatesBuffer || !visualStateTexture) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.paramsData[0] = this.mode;
|
||||
this.paramsData[1] = this.curveExp;
|
||||
this.paramsData[2] = 0;
|
||||
this.paramsData[3] = 0;
|
||||
this.paramsData[4] = updateCount;
|
||||
this.paramsData[5] = 0;
|
||||
this.paramsData[6] = 0;
|
||||
this.paramsData[7] = 0;
|
||||
this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsData);
|
||||
|
||||
const shouldRebuild =
|
||||
!this.bindGroup ||
|
||||
this.boundUpdatesBuffer !== updatesBuffer ||
|
||||
this.boundVisualStateTexture !== visualStateTexture;
|
||||
if (shouldRebuild) {
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
|
||||
if (!this.bindGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pass = encoder.beginComputePass();
|
||||
pass.setPipeline(this.pipeline);
|
||||
pass.setBindGroup(0, this.bindGroup);
|
||||
const workgroupCount = Math.ceil(updateCount / 64);
|
||||
pass.dispatchWorkgroups(workgroupCount);
|
||||
pass.end();
|
||||
}
|
||||
|
||||
private rebuildBindGroup(): void {
|
||||
if (
|
||||
!this.device ||
|
||||
!this.bindGroupLayout ||
|
||||
!this.resources ||
|
||||
!this.resources.temporalUniformBuffer ||
|
||||
!this.paramsBuffer ||
|
||||
!this.resources.updatesBuffer ||
|
||||
!this.resources.getVisualStateTexture()
|
||||
) {
|
||||
this.bindGroup = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const visualStateTexture = this.resources.getVisualStateTexture();
|
||||
if (!visualStateTexture) {
|
||||
this.bindGroup = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.bindGroup = this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
resource: { buffer: this.resources.temporalUniformBuffer },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
resource: { buffer: this.paramsBuffer },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
resource: { buffer: this.resources.updatesBuffer },
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
resource: visualStateTexture.createView(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.boundUpdatesBuffer = this.resources.updatesBuffer;
|
||||
this.boundVisualStateTexture = visualStateTexture;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.pipeline = null;
|
||||
this.bindGroupLayout = null;
|
||||
this.bindGroup = null;
|
||||
this.device = null;
|
||||
this.resources = null;
|
||||
this.paramsBuffer = null;
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,13 @@ export class GroundTruthData {
|
||||
public readonly ownerIndexTexture: GPUTexture;
|
||||
public readonly relationsTexture: GPUTexture;
|
||||
public readonly defendedStrengthTexture: GPUTexture;
|
||||
public visualStateTexture: GPUTexture | null = null;
|
||||
public currentColorTexture: GPUTexture | null = null;
|
||||
public historyColorTextures: [GPUTexture, GPUTexture] | null = null;
|
||||
|
||||
// Buffers
|
||||
public readonly uniformBuffer: GPUBuffer;
|
||||
public readonly temporalUniformBuffer: GPUBuffer;
|
||||
public readonly terrainParamsBuffer: GPUBuffer;
|
||||
public readonly stateUpdateParamsBuffer: GPUBuffer;
|
||||
public readonly defendedStrengthParamsBuffer: GPUBuffer;
|
||||
@@ -57,6 +61,19 @@ export class GroundTruthData {
|
||||
private needsPaletteUpload = true;
|
||||
private needsTerrainDataUpload = true;
|
||||
private needsTerrainParamsUpload = true;
|
||||
private useVisualStateTexture = false;
|
||||
private visualStateNeedsSync = false;
|
||||
private lastStateUpdateCount = 0;
|
||||
private historyIndex = 0;
|
||||
private historyValid = false;
|
||||
private postSmoothingWidth = 0;
|
||||
private postSmoothingHeight = 0;
|
||||
private postSmoothingFormat: GPUTextureFormat | null = null;
|
||||
private lastTickSec = 0;
|
||||
private tickDtSec = 0.1;
|
||||
private tickDtEmaSec = 0.1;
|
||||
private tickCount = 0;
|
||||
private readonly tickEmaAlpha = 0.2;
|
||||
private paletteWidth = 1;
|
||||
private needsDefensePostsUpload = true;
|
||||
private defensePostsTotalCount = 0;
|
||||
@@ -68,6 +85,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 stateUpdateParamsData = new Uint32Array(4); // updateCount, range, pad, pad
|
||||
private readonly defendedStrengthParamsData = new Uint32Array(4); // dirtyCount, range, pad, pad
|
||||
@@ -107,6 +125,7 @@ export class GroundTruthData {
|
||||
const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40;
|
||||
const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8;
|
||||
const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2;
|
||||
const COPY_SRC_TEX = GPUTextureUsage?.COPY_SRC ?? 0x1;
|
||||
const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4;
|
||||
const STORAGE_BINDING = GPUTextureUsage?.STORAGE_BINDING ?? 0x8;
|
||||
|
||||
@@ -116,6 +135,12 @@ export class GroundTruthData {
|
||||
usage: UNIFORM | COPY_DST_BUF,
|
||||
});
|
||||
|
||||
// Temporal uniforms: 2x vec4f = 32 bytes
|
||||
this.temporalUniformBuffer = device.createBuffer({
|
||||
size: 32,
|
||||
usage: UNIFORM | COPY_DST_BUF,
|
||||
});
|
||||
|
||||
// State update params: 4x u32 = 16 bytes
|
||||
this.stateUpdateParamsBuffer = device.createBuffer({
|
||||
size: 16,
|
||||
@@ -138,7 +163,7 @@ export class GroundTruthData {
|
||||
this.stateTexture = device.createTexture({
|
||||
size: { width: mapWidth, height: mapHeight },
|
||||
format: "r32uint",
|
||||
usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING,
|
||||
usage: COPY_DST_TEX | COPY_SRC_TEX | TEXTURE_BINDING | STORAGE_BINDING,
|
||||
});
|
||||
|
||||
// Defended strength texture (rgba8unorm, r channel used)
|
||||
@@ -233,13 +258,24 @@ export class GroundTruthData {
|
||||
}
|
||||
|
||||
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
|
||||
const eps = 1e-4;
|
||||
const changed =
|
||||
Math.abs(scale - this.viewScale) > eps ||
|
||||
Math.abs(offsetX - this.viewOffsetX) > eps ||
|
||||
Math.abs(offsetY - this.viewOffsetY) > eps;
|
||||
this.viewScale = scale;
|
||||
this.viewOffsetX = offsetX;
|
||||
this.viewOffsetY = offsetY;
|
||||
if (changed) {
|
||||
this.invalidateHistory();
|
||||
}
|
||||
}
|
||||
|
||||
setAlternativeView(enabled: boolean): void {
|
||||
this.alternativeView = enabled;
|
||||
if (this.alternativeView !== enabled) {
|
||||
this.alternativeView = enabled;
|
||||
this.invalidateHistory();
|
||||
}
|
||||
}
|
||||
|
||||
setHighlightedOwnerId(ownerSmallId: number | null): void {
|
||||
@@ -256,6 +292,190 @@ export class GroundTruthData {
|
||||
}
|
||||
}
|
||||
|
||||
setUseVisualStateTexture(enabled: boolean): void {
|
||||
this.useVisualStateTexture = enabled;
|
||||
if (enabled) {
|
||||
this.visualStateNeedsSync = true;
|
||||
}
|
||||
}
|
||||
|
||||
consumeVisualStateSyncNeeded(): boolean {
|
||||
if (!this.visualStateNeedsSync) {
|
||||
return false;
|
||||
}
|
||||
this.visualStateNeedsSync = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
ensureVisualStateTexture(): void {
|
||||
if (this.visualStateTexture) {
|
||||
return;
|
||||
}
|
||||
const GPUTextureUsage = (globalThis as any).GPUTextureUsage;
|
||||
const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2;
|
||||
const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4;
|
||||
const STORAGE_BINDING = GPUTextureUsage?.STORAGE_BINDING ?? 0x8;
|
||||
this.visualStateTexture = this.device.createTexture({
|
||||
size: { width: this.mapWidth, height: this.mapHeight },
|
||||
format: "r32uint",
|
||||
usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING,
|
||||
});
|
||||
}
|
||||
|
||||
releaseVisualStateTexture(): void {
|
||||
if (this.visualStateTexture) {
|
||||
(this.visualStateTexture as any).destroy?.();
|
||||
this.visualStateTexture = null;
|
||||
}
|
||||
}
|
||||
|
||||
getVisualStateTexture(): GPUTexture | null {
|
||||
return this.visualStateTexture;
|
||||
}
|
||||
|
||||
getRenderStateTexture(): GPUTexture {
|
||||
if (this.useVisualStateTexture && this.visualStateTexture) {
|
||||
return this.visualStateTexture;
|
||||
}
|
||||
return this.stateTexture;
|
||||
}
|
||||
|
||||
setLastStateUpdateCount(count: number): void {
|
||||
this.lastStateUpdateCount = Math.max(0, Math.floor(count));
|
||||
}
|
||||
|
||||
getLastStateUpdateCount(): number {
|
||||
return this.lastStateUpdateCount;
|
||||
}
|
||||
|
||||
updateTickTiming(nowSec: number): void {
|
||||
if (this.lastTickSec > 0) {
|
||||
const dt = Math.max(1e-3, nowSec - this.lastTickSec);
|
||||
this.tickDtSec = dt;
|
||||
this.tickDtEmaSec =
|
||||
this.tickDtEmaSec * (1 - this.tickEmaAlpha) + dt * this.tickEmaAlpha;
|
||||
}
|
||||
this.lastTickSec = nowSec;
|
||||
this.tickCount += 1;
|
||||
}
|
||||
|
||||
writeTemporalUniformBuffer(nowSec: number): void {
|
||||
const denom = Math.max(1e-3, this.tickDtEmaSec);
|
||||
const alpha = Math.max(0, Math.min(1, (nowSec - this.lastTickSec) / denom));
|
||||
|
||||
this.temporalData[0] = nowSec;
|
||||
this.temporalData[1] = this.lastTickSec;
|
||||
this.temporalData[2] = this.tickDtSec;
|
||||
this.temporalData[3] = this.tickDtEmaSec;
|
||||
this.temporalData[4] = alpha;
|
||||
this.temporalData[5] = this.tickCount;
|
||||
this.temporalData[6] = this.historyValid ? 1 : 0;
|
||||
this.temporalData[7] = 0;
|
||||
|
||||
this.device.queue.writeBuffer(
|
||||
this.temporalUniformBuffer,
|
||||
0,
|
||||
this.temporalData,
|
||||
);
|
||||
}
|
||||
|
||||
invalidateHistory(): void {
|
||||
this.historyValid = false;
|
||||
}
|
||||
|
||||
markHistoryValid(): void {
|
||||
this.historyValid = true;
|
||||
}
|
||||
|
||||
swapHistoryTextures(): void {
|
||||
if (!this.historyColorTextures) {
|
||||
return;
|
||||
}
|
||||
this.historyIndex = this.historyIndex === 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
ensurePostSmoothingTextures(
|
||||
width: number,
|
||||
height: number,
|
||||
format: GPUTextureFormat,
|
||||
): void {
|
||||
const w = Math.max(1, Math.floor(width));
|
||||
const h = Math.max(1, Math.floor(height));
|
||||
const needsRebuild =
|
||||
!this.currentColorTexture ||
|
||||
!this.historyColorTextures ||
|
||||
this.postSmoothingWidth !== w ||
|
||||
this.postSmoothingHeight !== h ||
|
||||
this.postSmoothingFormat !== format;
|
||||
|
||||
if (!needsRebuild) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.releasePostSmoothingTextures();
|
||||
|
||||
const GPUTextureUsage = (globalThis as any).GPUTextureUsage;
|
||||
const RENDER_ATTACHMENT = GPUTextureUsage?.RENDER_ATTACHMENT ?? 0x10;
|
||||
const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4;
|
||||
|
||||
this.currentColorTexture = this.device.createTexture({
|
||||
size: { width: w, height: h },
|
||||
format,
|
||||
usage: RENDER_ATTACHMENT | TEXTURE_BINDING,
|
||||
});
|
||||
const historyA = this.device.createTexture({
|
||||
size: { width: w, height: h },
|
||||
format,
|
||||
usage: RENDER_ATTACHMENT | TEXTURE_BINDING,
|
||||
});
|
||||
const historyB = this.device.createTexture({
|
||||
size: { width: w, height: h },
|
||||
format,
|
||||
usage: RENDER_ATTACHMENT | TEXTURE_BINDING,
|
||||
});
|
||||
|
||||
this.historyColorTextures = [historyA, historyB];
|
||||
this.historyIndex = 0;
|
||||
this.historyValid = false;
|
||||
this.postSmoothingWidth = w;
|
||||
this.postSmoothingHeight = h;
|
||||
this.postSmoothingFormat = format;
|
||||
}
|
||||
|
||||
releasePostSmoothingTextures(): void {
|
||||
if (this.currentColorTexture) {
|
||||
(this.currentColorTexture as any).destroy?.();
|
||||
this.currentColorTexture = null;
|
||||
}
|
||||
if (this.historyColorTextures) {
|
||||
(this.historyColorTextures[0] as any).destroy?.();
|
||||
(this.historyColorTextures[1] as any).destroy?.();
|
||||
this.historyColorTextures = null;
|
||||
}
|
||||
this.historyValid = false;
|
||||
this.postSmoothingWidth = 0;
|
||||
this.postSmoothingHeight = 0;
|
||||
this.postSmoothingFormat = null;
|
||||
}
|
||||
|
||||
getCurrentColorTexture(): GPUTexture | null {
|
||||
return this.currentColorTexture;
|
||||
}
|
||||
|
||||
getHistoryReadTexture(): GPUTexture | null {
|
||||
if (!this.historyColorTextures) {
|
||||
return null;
|
||||
}
|
||||
return this.historyColorTextures[this.historyIndex];
|
||||
}
|
||||
|
||||
getHistoryWriteTexture(): GPUTexture | null {
|
||||
if (!this.historyColorTextures) {
|
||||
return null;
|
||||
}
|
||||
return this.historyColorTextures[this.historyIndex === 0 ? 1 : 0];
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Upload methods
|
||||
// =====================
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { GroundTruthData } from "../core/GroundTruthData";
|
||||
import { loadShader } from "../core/ShaderLoader";
|
||||
import { RenderPass } from "./RenderPass";
|
||||
|
||||
/**
|
||||
* Post-render temporal resolve pass. Blends current and history frames.
|
||||
*/
|
||||
export class TemporalResolvePass implements RenderPass {
|
||||
name = "temporal-resolve";
|
||||
dependencies: string[] = ["territory"];
|
||||
|
||||
private pipeline: GPURenderPipeline | null = null;
|
||||
private bindGroupLayout: GPUBindGroupLayout | null = null;
|
||||
private bindGroup: GPUBindGroup | null = null;
|
||||
private device: GPUDevice | null = null;
|
||||
private resources: GroundTruthData | null = null;
|
||||
private canvasFormat: GPUTextureFormat | null = null;
|
||||
private paramsBuffer: GPUBuffer | null = null;
|
||||
private paramsData = new Float32Array(4);
|
||||
private enabled = false;
|
||||
private boundCurrentTexture: GPUTexture | null = null;
|
||||
private boundHistoryTexture: GPUTexture | null = null;
|
||||
|
||||
async init(
|
||||
device: GPUDevice,
|
||||
resources: GroundTruthData,
|
||||
canvasFormat: GPUTextureFormat,
|
||||
): Promise<void> {
|
||||
this.device = device;
|
||||
this.resources = resources;
|
||||
this.canvasFormat = canvasFormat;
|
||||
|
||||
const GPUBufferUsage = (globalThis as any).GPUBufferUsage;
|
||||
const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40;
|
||||
const COPY_DST = GPUBufferUsage?.COPY_DST ?? 0x8;
|
||||
this.paramsBuffer = device.createBuffer({
|
||||
size: 16,
|
||||
usage: UNIFORM | COPY_DST,
|
||||
});
|
||||
|
||||
this.bindGroupLayout = device.createBindGroupLayout({
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
buffer: { type: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
buffer: { type: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
texture: { sampleType: "float" },
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
texture: { sampleType: "float" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await this.setShader("render/temporal-resolve.wgsl");
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
|
||||
async setShader(shaderPath: string): Promise<void> {
|
||||
if (!this.device || !this.bindGroupLayout || !this.canvasFormat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shaderCode = await loadShader(shaderPath);
|
||||
const shaderModule = this.device.createShaderModule({ code: shaderCode });
|
||||
|
||||
this.pipeline = this.device.createRenderPipeline({
|
||||
layout: this.device.createPipelineLayout({
|
||||
bindGroupLayouts: [this.bindGroupLayout],
|
||||
}),
|
||||
vertex: { module: shaderModule, entryPoint: "vsMain" },
|
||||
fragment: {
|
||||
module: shaderModule,
|
||||
entryPoint: "fsMain",
|
||||
targets: [{ format: this.canvasFormat }, { format: this.canvasFormat }],
|
||||
},
|
||||
primitive: { topology: "triangle-list" },
|
||||
});
|
||||
}
|
||||
|
||||
setParams(params0: Float32Array | number[]): void {
|
||||
this.paramsData[0] = Number(params0[0] ?? 0);
|
||||
this.paramsData[1] = Number(params0[1] ?? 1);
|
||||
this.paramsData[2] = Number(params0[2] ?? 0.08);
|
||||
this.paramsData[3] = 0;
|
||||
this.enabled = this.paramsData[0] > 0;
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
needsUpdate(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
execute(
|
||||
encoder: GPUCommandEncoder,
|
||||
resources: GroundTruthData,
|
||||
target: GPUTextureView,
|
||||
): void {
|
||||
if (!this.device || !this.pipeline || !this.paramsBuffer) {
|
||||
return;
|
||||
}
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTexture = resources.getCurrentColorTexture();
|
||||
const historyRead = resources.getHistoryReadTexture();
|
||||
const historyWrite = resources.getHistoryWriteTexture();
|
||||
if (!currentTexture || !historyRead || !historyWrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsData);
|
||||
|
||||
const shouldRebuild =
|
||||
!this.bindGroup ||
|
||||
this.boundCurrentTexture !== currentTexture ||
|
||||
this.boundHistoryTexture !== historyRead;
|
||||
if (shouldRebuild) {
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
|
||||
if (!this.bindGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pass = encoder.beginRenderPass({
|
||||
colorAttachments: [
|
||||
{
|
||||
view: target,
|
||||
loadOp: "clear",
|
||||
storeOp: "store",
|
||||
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
||||
},
|
||||
{
|
||||
view: historyWrite.createView(),
|
||||
loadOp: "clear",
|
||||
storeOp: "store",
|
||||
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
pass.setPipeline(this.pipeline);
|
||||
pass.setBindGroup(0, this.bindGroup);
|
||||
pass.draw(3);
|
||||
pass.end();
|
||||
|
||||
resources.swapHistoryTextures();
|
||||
resources.markHistoryValid();
|
||||
}
|
||||
|
||||
rebuildBindGroup(): void {
|
||||
if (
|
||||
!this.device ||
|
||||
!this.bindGroupLayout ||
|
||||
!this.resources ||
|
||||
!this.resources.temporalUniformBuffer ||
|
||||
!this.paramsBuffer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTexture = this.resources.getCurrentColorTexture();
|
||||
const historyRead = this.resources.getHistoryReadTexture();
|
||||
if (!currentTexture || !historyRead) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bindGroup = this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
resource: { buffer: this.resources.temporalUniformBuffer },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
resource: { buffer: this.paramsBuffer },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
resource: currentTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
resource: historyRead.createView(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.boundCurrentTexture = currentTexture;
|
||||
this.boundHistoryTexture = historyRead;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.pipeline = null;
|
||||
this.bindGroupLayout = null;
|
||||
this.bindGroup = null;
|
||||
this.device = null;
|
||||
this.resources = null;
|
||||
this.paramsBuffer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { TerritoryShaderOption } from "./TerritoryShaderRegistry";
|
||||
|
||||
export type TerritoryPostSmoothingId = "off" | "fade" | "dissolve";
|
||||
|
||||
export interface TerritoryPostSmoothingDefinition {
|
||||
id: TerritoryPostSmoothingId;
|
||||
label: string;
|
||||
wgslPath: string;
|
||||
options: TerritoryShaderOption[];
|
||||
}
|
||||
|
||||
export const TERRITORY_POST_SMOOTHING_KEY =
|
||||
"settings.webgpu.territory.smoothing.post";
|
||||
|
||||
export const TERRITORY_POST_SMOOTHING: TerritoryPostSmoothingDefinition[] = [
|
||||
{
|
||||
id: "off",
|
||||
label: "Off",
|
||||
wgslPath: "",
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
id: "fade",
|
||||
label: "Fade",
|
||||
wgslPath: "render/temporal-resolve.wgsl",
|
||||
options: [
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.postSmoothing.blendStrength",
|
||||
label: "Blend Strength",
|
||||
defaultValue: 1,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "dissolve",
|
||||
label: "Dissolve",
|
||||
wgslPath: "render/temporal-resolve.wgsl",
|
||||
options: [
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.postSmoothing.blendStrength",
|
||||
label: "Blend Strength",
|
||||
defaultValue: 1,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.postSmoothing.dissolveWidth",
|
||||
label: "Dissolve Width",
|
||||
defaultValue: 0.08,
|
||||
min: 0.01,
|
||||
max: 0.4,
|
||||
step: 0.01,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function territoryPostSmoothingIdFromInt(
|
||||
value: number,
|
||||
): TerritoryPostSmoothingId {
|
||||
if (value === 1) return "fade";
|
||||
if (value === 2) return "dissolve";
|
||||
return "off";
|
||||
}
|
||||
|
||||
export function territoryPostSmoothingIntFromId(
|
||||
id: TerritoryPostSmoothingId,
|
||||
): number {
|
||||
if (id === "fade") return 1;
|
||||
if (id === "dissolve") return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function readTerritoryPostSmoothingId(userSettings: {
|
||||
getInt: (key: string, defaultValue: number) => number;
|
||||
}): TerritoryPostSmoothingId {
|
||||
return territoryPostSmoothingIdFromInt(
|
||||
userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildTerritoryPostSmoothingParams(
|
||||
userSettings: {
|
||||
getFloat: (key: string, defaultValue: number) => number;
|
||||
},
|
||||
smoothingId: TerritoryPostSmoothingId,
|
||||
): {
|
||||
enabled: boolean;
|
||||
shaderPath: string;
|
||||
params0: Float32Array;
|
||||
params1: Float32Array;
|
||||
} {
|
||||
if (smoothingId === "off") {
|
||||
return {
|
||||
enabled: false,
|
||||
shaderPath: "",
|
||||
params0: new Float32Array(4),
|
||||
params1: new Float32Array(4),
|
||||
};
|
||||
}
|
||||
|
||||
const blendStrength = userSettings.getFloat(
|
||||
"settings.webgpu.territory.postSmoothing.blendStrength",
|
||||
1,
|
||||
);
|
||||
const dissolveWidth = userSettings.getFloat(
|
||||
"settings.webgpu.territory.postSmoothing.dissolveWidth",
|
||||
0.08,
|
||||
);
|
||||
|
||||
const mode = smoothingId === "fade" ? 1 : 2;
|
||||
const params0 = new Float32Array([mode, blendStrength, dissolveWidth, 0]);
|
||||
const params1 = new Float32Array([0, 0, 0, 0]);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
shaderPath: "render/temporal-resolve.wgsl",
|
||||
params0,
|
||||
params1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { TerritoryShaderOption } from "./TerritoryShaderRegistry";
|
||||
|
||||
export type TerritoryPreSmoothingId = "off" | "dissolve" | "budget";
|
||||
|
||||
export interface TerritoryPreSmoothingDefinition {
|
||||
id: TerritoryPreSmoothingId;
|
||||
label: string;
|
||||
wgslPath: string;
|
||||
options: TerritoryShaderOption[];
|
||||
}
|
||||
|
||||
export const TERRITORY_PRE_SMOOTHING_KEY =
|
||||
"settings.webgpu.territory.smoothing.pre";
|
||||
|
||||
export const TERRITORY_PRE_SMOOTHING: TerritoryPreSmoothingDefinition[] = [
|
||||
{
|
||||
id: "off",
|
||||
label: "Off",
|
||||
wgslPath: "",
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
id: "dissolve",
|
||||
label: "Dissolve",
|
||||
wgslPath: "compute/visual-state-smoothing.wgsl",
|
||||
options: [
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.preSmoothing.curveExp",
|
||||
label: "Reveal Curve",
|
||||
defaultValue: 1,
|
||||
min: 0.25,
|
||||
max: 3,
|
||||
step: 0.05,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "budget",
|
||||
label: "Budgeted Reveal",
|
||||
wgslPath: "compute/visual-state-smoothing.wgsl",
|
||||
options: [
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.preSmoothing.curveExp",
|
||||
label: "Reveal Curve",
|
||||
defaultValue: 1,
|
||||
min: 0.25,
|
||||
max: 3,
|
||||
step: 0.05,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function territoryPreSmoothingIdFromInt(
|
||||
value: number,
|
||||
): TerritoryPreSmoothingId {
|
||||
if (value === 1) return "dissolve";
|
||||
if (value === 2) return "budget";
|
||||
return "off";
|
||||
}
|
||||
|
||||
export function territoryPreSmoothingIntFromId(
|
||||
id: TerritoryPreSmoothingId,
|
||||
): number {
|
||||
if (id === "dissolve") return 1;
|
||||
if (id === "budget") return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function readTerritoryPreSmoothingId(userSettings: {
|
||||
getInt: (key: string, defaultValue: number) => number;
|
||||
}): TerritoryPreSmoothingId {
|
||||
return territoryPreSmoothingIdFromInt(
|
||||
userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildTerritoryPreSmoothingParams(
|
||||
userSettings: {
|
||||
getFloat: (key: string, defaultValue: number) => number;
|
||||
},
|
||||
smoothingId: TerritoryPreSmoothingId,
|
||||
): {
|
||||
enabled: boolean;
|
||||
shaderPath: string;
|
||||
params0: Float32Array;
|
||||
params1: Float32Array;
|
||||
} {
|
||||
if (smoothingId === "off") {
|
||||
return {
|
||||
enabled: false,
|
||||
shaderPath: "",
|
||||
params0: new Float32Array(4),
|
||||
params1: new Float32Array(4),
|
||||
};
|
||||
}
|
||||
|
||||
const curveExp = userSettings.getFloat(
|
||||
"settings.webgpu.territory.preSmoothing.curveExp",
|
||||
1,
|
||||
);
|
||||
const mode = smoothingId === "dissolve" ? 1 : 2;
|
||||
|
||||
const params0 = new Float32Array([mode, curveExp, 0, 0]);
|
||||
const params1 = new Float32Array([0, 0, 0, 0]);
|
||||
return {
|
||||
enabled: true,
|
||||
shaderPath: "compute/visual-state-smoothing.wgsl",
|
||||
params0,
|
||||
params1,
|
||||
};
|
||||
}
|
||||
@@ -157,7 +157,6 @@ export class TerritoryRenderPass implements RenderPass {
|
||||
!this.bindGroupLayout ||
|
||||
!this.resources ||
|
||||
!this.resources.uniformBuffer ||
|
||||
!this.resources.stateTexture ||
|
||||
!this.resources.defendedStrengthTexture ||
|
||||
!this.resources.paletteTexture ||
|
||||
!this.resources.terrainTexture ||
|
||||
@@ -167,13 +166,15 @@ export class TerritoryRenderPass implements RenderPass {
|
||||
return;
|
||||
}
|
||||
|
||||
const stateTexture = this.resources.getRenderStateTexture();
|
||||
|
||||
this.bindGroup = this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.resources.uniformBuffer } },
|
||||
{
|
||||
binding: 1,
|
||||
resource: this.resources.stateTexture.createView(),
|
||||
resource: stateTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
struct Temporal {
|
||||
nowSec: f32,
|
||||
lastTickSec: f32,
|
||||
tickDtSec: f32,
|
||||
tickDtEmaSec: f32,
|
||||
tickAlpha: f32,
|
||||
tickCount: f32,
|
||||
historyValid: f32,
|
||||
_pad0: f32,
|
||||
};
|
||||
|
||||
struct Params {
|
||||
params0: vec4f, // x=mode, y=curveExp
|
||||
params1: vec4f, // x=updateCount
|
||||
};
|
||||
|
||||
struct Update {
|
||||
tileIndex: u32,
|
||||
newState: u32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> t: Temporal;
|
||||
@group(0) @binding(1) var<uniform> p: Params;
|
||||
@group(0) @binding(2) var<storage, read> updates: array<Update>;
|
||||
@group(0) @binding(3) var visualStateTex: texture_storage_2d<r32uint, write>;
|
||||
|
||||
fn hashUint(x: u32) -> u32 {
|
||||
var h = x * 1664525u + 1013904223u;
|
||||
h ^= h >> 16u;
|
||||
h *= 2246822519u;
|
||||
h ^= h >> 13u;
|
||||
h *= 3266489917u;
|
||||
h ^= h >> 16u;
|
||||
return h;
|
||||
}
|
||||
|
||||
fn hashToUnitFloat(x: u32) -> f32 {
|
||||
return f32(x & 0x00FFFFFFu) / 16777216.0;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
||||
let idx = globalId.x;
|
||||
let updateCount = u32(max(0.0, p.params1.x) + 0.5);
|
||||
if (idx >= updateCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mode = u32(max(0.0, p.params0.x) + 0.5);
|
||||
let curveExp = max(0.001, p.params0.y);
|
||||
let alpha = clamp(pow(clamp(t.tickAlpha, 0.0, 1.0), curveExp), 0.0, 1.0);
|
||||
|
||||
let update = updates[idx];
|
||||
|
||||
if (mode == 1u) {
|
||||
let tickSeed = u32(max(0.0, t.tickCount) + 0.5);
|
||||
let h = hashUint(update.tileIndex ^ (tickSeed * 2654435761u));
|
||||
let r = hashToUnitFloat(h);
|
||||
if (r > alpha) {
|
||||
return;
|
||||
}
|
||||
} else if (mode == 2u) {
|
||||
let targetCount = u32(floor(f32(updateCount) * alpha));
|
||||
if (idx >= targetCount) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
let dims = textureDimensions(visualStateTex);
|
||||
let mapWidth = dims.x;
|
||||
let x = i32(update.tileIndex % mapWidth);
|
||||
let y = i32(update.tileIndex / mapWidth);
|
||||
textureStore(visualStateTex, vec2i(x, y), vec4u(update.newState, 0u, 0u, 0u));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
struct Temporal {
|
||||
nowSec: f32,
|
||||
lastTickSec: f32,
|
||||
tickDtSec: f32,
|
||||
tickDtEmaSec: f32,
|
||||
tickAlpha: f32,
|
||||
tickCount: f32,
|
||||
historyValid: f32,
|
||||
_pad0: f32,
|
||||
};
|
||||
|
||||
struct Params {
|
||||
params0: vec4f, // x=mode, y=blendStrength, z=dissolveWidth
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> t: Temporal;
|
||||
@group(0) @binding(1) var<uniform> p: Params;
|
||||
@group(0) @binding(2) var currentTex: texture_2d<f32>;
|
||||
@group(0) @binding(3) var historyTex: texture_2d<f32>;
|
||||
|
||||
struct FragOutput {
|
||||
@location(0) color: vec4f,
|
||||
@location(1) history: vec4f,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
||||
var pos = array<vec2f, 3>(
|
||||
vec2f(-1.0, -1.0),
|
||||
vec2f(3.0, -1.0),
|
||||
vec2f(-1.0, 3.0),
|
||||
);
|
||||
let p = pos[vi];
|
||||
return vec4f(p, 0.0, 1.0);
|
||||
}
|
||||
|
||||
fn hashUint(x: u32) -> u32 {
|
||||
var h = x * 1664525u + 1013904223u;
|
||||
h ^= h >> 16u;
|
||||
h *= 2246822519u;
|
||||
h ^= h >> 13u;
|
||||
h *= 3266489917u;
|
||||
h ^= h >> 16u;
|
||||
return h;
|
||||
}
|
||||
|
||||
fn hashToUnitFloat(x: u32) -> f32 {
|
||||
return f32(x & 0x00FFFFFFu) / 16777216.0;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fsMain(@builtin(position) pos: vec4f) -> FragOutput {
|
||||
let texCoord = vec2i(pos.xy);
|
||||
let curr = textureLoad(currentTex, texCoord, 0);
|
||||
let hist = textureLoad(historyTex, texCoord, 0);
|
||||
|
||||
let mode = u32(max(0.0, p.params0.x) + 0.5);
|
||||
let strength = clamp(p.params0.y, 0.0, 1.0);
|
||||
let width = max(0.001, p.params0.z);
|
||||
|
||||
var alpha = clamp(t.tickAlpha * strength, 0.0, 1.0);
|
||||
if (t.historyValid < 0.5) {
|
||||
alpha = 1.0;
|
||||
}
|
||||
|
||||
if (mode == 1u) {
|
||||
let outColor = mix(hist, curr, alpha);
|
||||
return FragOutput(outColor, outColor);
|
||||
}
|
||||
|
||||
if (mode == 2u) {
|
||||
let seed = (u32(texCoord.x) * 73856093u) ^ (u32(texCoord.y) * 19349663u);
|
||||
let tickSeed = u32(max(0.0, t.tickCount) + 0.5);
|
||||
let r = hashToUnitFloat(hashUint(seed ^ (tickSeed * 2654435761u)));
|
||||
let mask = smoothstep(alpha - width, alpha + width, r);
|
||||
let outColor = mix(hist, curr, mask);
|
||||
return FragOutput(outColor, outColor);
|
||||
}
|
||||
|
||||
return FragOutput(curr, curr);
|
||||
}
|
||||
Reference in New Issue
Block a user