Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions src/@types/rendering/ProceduralDiskInstance.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { SourceType } from '../data/SourceType';

/**
* ProceduralDiskInstance — one entry in the procedural-disk pass's
* per-instance vertex buffer. Mirrors the texture-based `DiskInstance`
Expand Down Expand Up @@ -66,4 +68,12 @@ export type ProceduralDiskInstance = {
* docblock above. Default 1.0 (no fade-out).
*/
procFadeOut: number;
/**
* Source + row identity of the galaxy this disk represents. CPU-only:
* these two fields are not in the nine-float vertex layout above — a
* later step packs them into a dedicated pick vertex slot.
*/
sourceCode: SourceType;
/** Per-source catalog row index — the localIdx half of the packed pick id. */
localIdx: number;
};
7 changes: 7 additions & 0 deletions src/@types/rendering/ProceduralDiskRenderer.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ export type ProceduralDiskRenderer = {
focusBindGroup: GPUBindGroup,
instances: ReadonlyArray<ProceduralDiskInstance>,
): void;
/**
* Draw the retained procedural-disk instances into the active pick
* render pass using the r32uint pick pipeline. No-op until 'draw' has
* uploaded at least one instance this frame. Caller (pickRenderer) has
* already bound the shared camera + focus state on the pick pass.
*/
pickDisks(pass: GPURenderPassEncoder): void;
/** Release the uniform + per-instance vertex buffers. */
destroy(): void;
};
6 changes: 3 additions & 3 deletions src/@types/settings/EngineSettingsState.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,9 @@ export type EngineSettingsState = {
* - `showPickBuffer` — colour-maps the r32uint pick texture and
* composites it over the tone-mapped frame. Lets a developer
* see which billboard the hover/click resolver actually claims
* at each pixel (including the +PICK_PADDING_PX boost and the
* 1.5× forgiveness ellipse/circle baked into pickFragment.wesl).
* Gated behind the DebugPanel.
* at each pixel. Point billboards show a `pointSizePx`-clamped
* dot; resolved galaxy disks are picked by the procedural-disk
* pass at the disk edge. Gated behind the DebugPanel.
* - `showDiskRadiusRing` — outlines each famous-galaxy thumbnail's
* disk-radius footprint so the developer can calibrate the
* placement against the underlying billboard. Gated behind the
Expand Down
1 change: 1 addition & 0 deletions src/services/engine/phases/wireInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export async function wireInput(state: EngineState, deps: BootstrapDeps): Promis
// of a focused structure from hit-testing (vertex shader culls them).
state.gpu.focusUniform!.bindGroup,
state.gpu.structureMarkerRenderer ?? undefined,
state.gpu.proceduralDiskRenderer ?? undefined,
);
state.gpu.pickRenderer = pickRenderer;
// The resolver hands back the freshly-decoded `(source, localIdx)`
Expand Down
6 changes: 6 additions & 0 deletions src/services/engine/subsystems/proceduralDiskSubsystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export function maybeEmitProceduralDisk(
colourIndex: number,
fadeStartPx: number,
fadeEndPx: number,
sourceCode: SourceType,
localIdx: number,
): ProceduralDiskInstance | null {
if (px <= fadeStartPx) return null;
if (!Number.isFinite(ar) || !Number.isFinite(pa)) return null;
Expand All @@ -89,6 +91,8 @@ export function maybeEmitProceduralDisk(
colourIndex,
crossfadeAlpha,
procFadeOut: 1.0,
sourceCode,
localIdx,
};
}

Expand Down Expand Up @@ -213,6 +217,8 @@ export function createProceduralDiskSubsystem(
colourIndex,
PROCEDURAL_DISK_FADE_START_PX,
PROCEDURAL_DISK_FADE_END_PX,
cloudSource,
i,
);
if (emitted) {
// Famous-WebP crossfade-OUT. For Famous-source galaxies
Expand Down
18 changes: 16 additions & 2 deletions src/services/gpu/renderers/pickRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
* The pick pipeline reads `PointRenderer`'s vertex + uniform buffers
* directly — callers must run the visual pass first so this frame's
* viewProj/viewport/etc are already written when `pick()` fires.
* `pickFragment.wesl`'s 1.5× forgiveness radius makes each pick
* billboard a bit larger than its visible disk.
* Point billboards pick a `pointSizePx`-clamped dot; resolved galaxy
* disks are picked by the procedural-disk pass at the disk edge.
*
* @module
*/
Expand All @@ -34,6 +34,7 @@ import type { FadeUniformsBgl } from '../../../@types/rendering/FadeUniformsBgl'
import type { SourceUniformsBgl } from '../../../@types/rendering/SourceUniformsBgl';
import type { FocusUniformsBgl } from '../../../@types/rendering/FocusUniformsBgl';
import type { StructureMarkerRenderer } from '../../../@types/rendering/StructureMarkerRenderer';
import type { ProceduralDiskRenderer } from '../../../@types/rendering/ProceduralDiskRenderer';
import {
POINT_STRIDE,
POINT_VERTEX_ATTRIBUTES,
Expand Down Expand Up @@ -81,6 +82,12 @@ export function createPickRenderer(
// Optional so tests can construct the picker in isolation; passing
// `undefined` yields a galaxy-only pick pass.
structureMarkerRenderer?: StructureMarkerRenderer,
// Optional procedural-disk pick provider. When present, the pick
// pass calls `proceduralDiskRenderer.pickDisks(pass)` so resolved
// galaxies (in the 8 px+ band) are pickable via their disk surface
// rather than only their companion point billboard. Optional so
// tests and pre-init paths can omit it.
proceduralDiskRenderer?: ProceduralDiskRenderer,
): PickRenderer {
const vsModule = createShaderModuleWithDevLog(device, vsCode, 'pick.vertex');
const fsModule = createShaderModuleWithDevLog(device, pickFsCode, 'pick.pickFragment');
Expand Down Expand Up @@ -337,6 +344,13 @@ export function createPickRenderer(
structureMarkerRenderer.pickRing(pass);
}

// Procedural-disk pick: shared depth means a closer point dot or
// disk claims the pixel; the disk and its companion point carry the
// SAME packed id, so overlap is harmless.
if (proceduralDiskRenderer) {
proceduralDiskRenderer.pickDisks(pass);
}

pass.end();
return pt;
}
Expand Down
222 changes: 218 additions & 4 deletions src/services/gpu/renderers/proceduralDiskRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@

import vsCode from '../shaders/proceduralDisks/vertex.wesl?static';
import fsCode from '../shaders/proceduralDisks/fragment.wesl?static';
import pickFsCode from '../shaders/proceduralDisks/pickFragment.wesl?static';
import type { ProceduralDiskInstance } from '../../../@types/rendering/ProceduralDiskInstance';
import type { ProceduralDiskRenderer } from '../../../@types/rendering/ProceduralDiskRenderer';
import type { Renderer } from '../../../@types/rendering/Renderer';
import type { Vec3 } from '../../../@types/math/Vec3';
import type { FocusUniformsBgl } from '../../../@types/rendering/FocusUniformsBgl';
import { FLOATS_PER_INSTANCE, createInstancedQuadRenderer } from './instancedQuadRenderer';
import { FLOATS_PER_INSTANCE, BYTES_PER_INSTANCE, UNIFORM_BYTES, createInstancedQuadRenderer } from './instancedQuadRenderer';
import { packSelection } from '../../../data/selectionEncoding';
import { createShaderModuleWithDevLog } from '../shaderCompileLogger';

type Init = {
device: GPUDevice;
Expand Down Expand Up @@ -71,6 +74,130 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer
uniformVisibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
});

// ── Pick pipeline ────────────────────────────────────────────────────
//
// The visual pipeline is owned by the instancedQuadRenderer factory and
// its BGL is NOT exposed. We own a SECOND pipeline (and its own camera
// BGL + uniform buffer) for the pick pass — same "own-everything"
// pattern as structureMarkerRenderer.
//
// Pipeline layout:
// @group(0) — own camera uniform (viewProj + viewport + camPos +
// pxPerRad), same 96-byte shape as the visual pipeline's
// @group(0). Visibility VERTEX | FRAGMENT matches the
// `uniformVisibility` flag passed to the visual pipeline
// above (see the module-header rationale for widening to
// FRAGMENT even though the vertex stage is the only reader).
// @group(1) — focusBgl (shared with the visual pipeline; identical
// layout identity).
//
// The vertex source is the SAME 'vertex.wesl' the visual pipeline uses
// (same @group declarations), but compiled into a SEPARATE
// GPUShaderModule so `layout:'auto'` isolation is never an issue. The
// fragment source is the new 'pickFragment.wesl' (entry 'fsPick').
const pickCameraBgl = init.device.createBindGroupLayout({
label: 'proceduralDisks-pick-camera-bgl',
entries: [
{
binding: 0,
// VERTEX | FRAGMENT mirrors the visual pipeline's uniformVisibility
// setting — keeps BGL identity stable if anyone ever compares the
// two for compatibility. The pick fragment doesn't read 'u', but
// the declaration must match what the vertex module declares.
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: 'uniform' },
},
],
});

const pickUniformBuffer = init.device.createBuffer({
label: 'proceduralDisks-pick-uniforms',
size: UNIFORM_BYTES,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});

const pickUniformBindGroup = init.device.createBindGroup({
label: 'proceduralDisks-pick-camera-bg',
layout: pickCameraBgl,
entries: [{ binding: 0, resource: { buffer: pickUniformBuffer } }],
});

const pickVsModule = createShaderModuleWithDevLog(
init.device,
vsCode,
'proceduralDisks.pick.vs',
);
const pickFsModule = createShaderModuleWithDevLog(
init.device,
pickFsCode,
'proceduralDisks.pick.fs',
);

const pickPipeline = init.device.createRenderPipeline({
label: 'proceduralDisks-pick-pipeline',
layout: init.device.createPipelineLayout({
label: 'proceduralDisks-pick-pipeline-layout',
bindGroupLayouts: [pickCameraBgl, init.focusBgl],
}),
vertex: {
module: pickVsModule,
entryPoint: 'vs',
// Exact same 64-byte / 16-float instance layout the visual pipeline
// uses. The pick pipeline reads the same per-instance data from the
// owned pick instance buffer.
buffers: [
{
arrayStride: BYTES_PER_INSTANCE,
stepMode: 'instance',
attributes: [
{ shaderLocation: 0, offset: 0, format: 'float32x4' },
{ shaderLocation: 1, offset: 16, format: 'float32x4' },
{ shaderLocation: 2, offset: 32, format: 'float32x4' },
{ shaderLocation: 3, offset: 48, format: 'float32x4' },
],
},
],
},
fragment: {
module: pickFsModule,
entryPoint: 'fsPick',
// r32uint: no blend — integer formats don't support blending.
targets: [{ format: 'r32uint' }],
},
primitive: { topology: 'triangle-list' },
// Depth test matches the galaxy and ring pick pipelines: front-most
// wins, depth write enabled. The depth attachment is shared across
// all pick draws in the same pass encoder.
depthStencil: {
format: 'depth24plus',
depthWriteEnabled: true,
depthCompare: 'less',
},
});

// Reusable pick uniform scratch — same shape as the visual pipeline's
// uniformScratch in instancedQuadRenderer.
const pickUniformScratch = new Float32Array(UNIFORM_BYTES / 4);

// Pick instance buffer — owned by this renderer, separate from the
// visual instance buffer inside 'inner' (which is private to the
// factory). Unlike structureMarkerRenderer, which rebinds one shared
// buffer across its visible and pick pipelines, this renderer must
// allocate a second, byte-identical buffer — the factory gives us no
// other handle.
let pickInstanceBuffer: GPUBuffer | null = null;
let pickInstanceBufferCapacity = 0; // measured in instances
let lastPickInstanceCount = 0;

// Cached camera values from the last draw() call. The pick pass runs
// AFTER draw() in the same frame, so these are always the current
// frame's camera state when pickDisks() is called.
let cachedViewProj: Float32Array = new Float32Array(16);
let cachedViewport: [number, number] = [0, 0];
let cachedCamPosWorld: Readonly<Vec3> = [0, 0, 0];
let cachedPxPerRad = 0;
let cachedFocusBindGroup: GPUBindGroup | null = null;

function draw(
pass: GPURenderPassEncoder,
viewProj: Float32Array,
Expand All @@ -80,12 +207,26 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer
focusBindGroup: GPUBindGroup,
instances: ReadonlyArray<ProceduralDiskInstance>,
): void {
if (instances.length === 0) return;
if (instances.length === 0) {
// Zero the cached count so pickDisks() no-ops — it replays
// lastPickInstanceCount from the previous frame, so a frame that
// drops to 0 disks must clear it or pickDisks() would re-draw the
// prior frame's disks into the pick texture.
lastPickInstanceCount = 0;
return;
}

// Fresh allocation per frame. The typical-frame size for the
// procedural pass is a few KB; GC churn isn't load-bearing today.
// A reusable scratch buffer can be added if profiling flags it.
const packed = new Float32Array(instances.length * FLOATS_PER_INSTANCE);
// Alias the same ArrayBuffer as u32 so we can write pick ids into
// float slots without float-precision loss. localIdx can exceed 2^24,
// which isn't exactly representable as f32; writing the raw u32 bits
// preserves all 27 localIdx bits. The shader reads them back with
// bitcast<u32>. The alternative — storing localIdx as a plain f32 —
// would silently corrupt ids above ~16 M.
const packedU32 = new Uint32Array(packed.buffer);
for (let i = 0; i < instances.length; i++) {
const o = i * FLOATS_PER_INSTANCE;
const ins = instances[i]!;
Expand All @@ -95,7 +236,9 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer
packed[o + 3] = ins.sizeWorldMpc;
packed[o + 4] = ins.axisRatio;
packed[o + 5] = ins.positionAngleDeg;
packed[o + 6] = 0;
// Slot 6 is the shader's 'orientation.z' (see proceduralDisks/io.wesl) —
// the pick pass bitcasts it back to the packed (source, localIdx) id.
packedU32[o + 6] = packSelection(ins.sourceCode, ins.localIdx);
packed[o + 7] = 0;
packed[o + 8] = ins.colourIndex;
packed[o + 9] = ins.crossfadeAlpha;
Expand All @@ -122,12 +265,83 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer
pxPerRad,
focusBindGroup,
});

// Cache camera state for pickDisks() — the pick pass runs after this
// draw() in the same frame; caching here avoids re-passing arguments.
cachedViewProj = viewProj;
cachedViewport = viewport;
cachedCamPosWorld = camPosWorld;
cachedPxPerRad = pxPerRad;
cachedFocusBindGroup = focusBindGroup;

// ── Pick instance buffer (mirror of the visual upload) ─────────────
//
// We own a second GPU buffer holding the same byte-identical packed
// data. The pickDisks() method binds this to the pick pipeline's
// vertex slot. Separate from the visual buffer because the factory
// keeps its instance buffer private.
//
// Why VERTEX | COPY_DST: instance buffers consumed by a draw call
// must carry VERTEX; COPY_DST is required by writeBuffer. Mirrors
// the usage flags on the visual instance buffer inside
// instancedQuadRenderer (see its grow block).
if (pickInstanceBuffer === null || pickInstanceBufferCapacity < instances.length) {
pickInstanceBuffer?.destroy();
pickInstanceBuffer = init.device.createBuffer({
label: 'proceduralDisks-pick-instances',
size: instances.length * BYTES_PER_INSTANCE,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
pickInstanceBufferCapacity = instances.length;
}
init.device.queue.writeBuffer(pickInstanceBuffer, 0, packed);
lastPickInstanceCount = instances.length; // consumed by pickDisks() to issue the instanced draw
}

function pickDisks(pass: GPURenderPassEncoder): void {
// No-op until draw() has uploaded at least one instance this frame
// (so we have live camera values + a populated pick instance buffer).
if (lastPickInstanceCount === 0 || pickInstanceBuffer === null) return;
if (cachedFocusBindGroup === null) return;

// Write the pick uniform buffer with this frame's camera values.
// Same 96-byte layout as the visual pipeline's uniformScratch:
// f32[ 0..15] viewProj f32[16..17] viewport f32[18..19] reserved
// f32[20..22] camPosWorld f32[23] pxPerRad
pickUniformScratch.set(cachedViewProj, 0);
pickUniformScratch[16] = cachedViewport[0];
pickUniformScratch[17] = cachedViewport[1];
pickUniformScratch[18] = 0;
pickUniformScratch[19] = 0;
pickUniformScratch[20] = cachedCamPosWorld[0];
pickUniformScratch[21] = cachedCamPosWorld[1];
pickUniformScratch[22] = cachedCamPosWorld[2];
pickUniformScratch[23] = cachedPxPerRad;
init.device.queue.writeBuffer(pickUniformBuffer, 0, pickUniformScratch);

pass.setPipeline(pickPipeline);
// @group(0): own pick camera uniforms (viewProj / viewport / camPos / pxPerRad).
pass.setBindGroup(0, pickUniformBindGroup);
// @group(1): shared focus uniform. Shared depth state means a closer
// point dot or disk claims the pixel; the disk and its companion point
// carry the SAME packed id, so overlap is harmless.
pass.setBindGroup(1, cachedFocusBindGroup);
pass.setVertexBuffer(0, pickInstanceBuffer);
pass.draw(6, lastPickInstanceCount);
}

function destroy(): void {
inner.destroy();
pickUniformBuffer.destroy();
pickInstanceBuffer?.destroy();
pickInstanceBuffer = null;
}

const renderer: ProceduralDiskRenderer = {
label: 'proceduralDiskRenderer',
draw,
destroy: inner.destroy,
pickDisks,
destroy,
};
// 'satisfies Renderer' confirms the shared label+destroy contract
// without widening the static type seen by consumers.
Expand Down
Loading
Loading