From 73c6629df20291bad1620ff6a580f73226e2d259 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 5 May 2026 16:15:19 +0100 Subject: [PATCH 01/24] Add design spec and implementation plan for color pipeline --- .../2026-04-29-healpix-cell-color-pipeline.md | 467 +++++++++++++ ...4-28-healpix-cell-color-pipeline-design.md | 624 ++++++++++++++++++ 2 files changed, 1091 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-29-healpix-cell-color-pipeline.md create mode 100644 docs/superpowers/specs/2026-04-28-healpix-cell-color-pipeline-design.md diff --git a/docs/superpowers/plans/2026-04-29-healpix-cell-color-pipeline.md b/docs/superpowers/plans/2026-04-29-healpix-cell-color-pipeline.md new file mode 100644 index 0000000..f47c0d6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-healpix-cell-color-pipeline.md @@ -0,0 +1,467 @@ +# HEALPix Cell Color Pipeline Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build filtering, scalar rescaling, and color assignment as separate GPU shader modules composed through `HealpixCellsPrimitiveLayer.getShaders()`. + +**Architecture:** `HealpixCellsPrimitiveLayer` owns the render pipeline. `dimensions` is the source value count per cell and drives values texture packing. `colorMode` controls how selected values are filtered, rescaled, and colored. The required `healpixValuesShaderModule` declares shared pipeline context, provides `healpixValueAt(channel)` for all packed source channels, and initializes `vec4 healpixSelectedValues`; stage modules inject code into `fs:DECKGL_FILTER_COLOR` and mutate selected values or the hook `color` parameter. User modules are passed through `shaderModules?: ShaderModule[]` and can inject into `fs:HEALPIX_SELECT_VALUES` or `fs:HEALPIX_RESCALE_VALUES`. + +```mermaid +flowchart TD + A["Layer props
values, dimensions, colorMode"] --> B["resolveFrame()
defaults colorMode to HEALPIX_COLOR_MODE_SCALAR"] + B --> C["packValuesData()
texelsPerCell = ceil(dimensions / 4)"] + C --> D["healpixValuesTexture
raw channels packed as RGBA texels"] + D --> E["healpixValues module
sets healpixDimensions and healpixColorMode"] + E --> F["healpixValueAt(channel)
reads any channel below dimensions"] + F --> G["Default selection
healpixSelectedValues = channels 0..3"] + G --> H["fs:HEALPIX_SELECT_VALUES
user may replace selected values"] + H --> I{"colorMode"} + I -->|"SCALAR or SCALAR_ALPHA"| J["healpixFilter
filterMin/filterMax on selectedValues.x"] + J --> K["healpixRescale
rescale selectedValues.x"] + K --> L["fs:HEALPIX_RESCALE_VALUES
user may adjust rescaled values"] + L --> M["healpixColor
colorMap lookup, optional alpha"] + I -->|"RGB or RGBA"| N["healpixColor
direct selected RGB/RGBA"] + M --> O["Fragment color"] + N --> O +``` + +**Tech Stack:** TypeScript, deck.gl `Layer`, luma.gl shader modules, GLSL ES 3.00, Jest, React sandbox example. + +--- + +## File Structure + +- Modify `packages/deck.gl-healpix/src/types/layer-props.ts`: add `colorMode`, `filterMin/filterMax/rescaleMin/rescaleMax`, and root-level `shaderModules`. +- Modify `packages/deck.gl-healpix/src/utils/resolve-frame.ts`: resolve `colorMode` and range props with `min/max` compatibility fallback. +- Modify `packages/deck.gl-healpix/src/utils/resolve-frame.test.ts`: cover `colorMode` defaults, frame overrides, range defaults, and fallback. +- Modify `packages/deck.gl-healpix/src/utils/values-texture.ts`: pack `ceil(dimensions / 4)` texels per cell. +- Modify `packages/deck.gl-healpix/src/utils/values-texture.test.ts`: cover multi-texel cell packing. +- Modify `packages/deck.gl-healpix/src/layers/healpix-cells-layer.ts`: pass resolved pipeline props, forward `shaderModules`, and stop appending `HEALPIX_COLOR_EXTENSION`. +- Modify `packages/deck.gl-healpix/src/layers/healpix-cells-primitive-layer.ts`: register `healpixCellIndex`, compose built-in and user modules, bind `colorMode` and values packing props. +- Modify `packages/deck.gl-healpix/src/shaders/healpix-cells.vs.glsl.ts`: pass `healpixCellIndex` to the fragment shader. +- Keep `packages/deck.gl-healpix/src/shaders/healpix-cells.fs.glsl.ts` minimal: initialize `fragColor` and call `DECKGL_FILTER_COLOR`. +- Create `packages/deck.gl-healpix/src/shaders/healpix-values-shader-module.ts`: shared context, value accessor, default selected values, and selector hook. +- Create `packages/deck.gl-healpix/src/shaders/healpix-filter-shader-module.ts`: optional filter/discard stage injection. +- Create `packages/deck.gl-healpix/src/shaders/healpix-rescale-shader-module.ts`: optional scalar rescale stage injection plus rescale hook. +- Create `packages/deck.gl-healpix/src/shaders/healpix-color-shader-module.ts`: color assignment stage injection. +- Create `packages/deck.gl-healpix/src/shaders/healpix-color-pipeline.test.ts`: structure/order tests. +- Delete `packages/deck.gl-healpix/src/extensions/healpix-color-extension.ts`. +- Delete `packages/deck.gl-healpix/src/extensions/healpix-color-shader-module.ts`. +- Modify `examples/sandbox/app/pages/color/index.tsx`: use `rescaleMin/rescaleMax` and add scalar filter controls. + +--- + +### Task 1: Resolve Color Mode, Filter, And Rescale Props + +**Files:** +- Modify: `packages/deck.gl-healpix/src/types/layer-props.ts` +- Modify: `packages/deck.gl-healpix/src/utils/resolve-frame.ts` +- Test: `packages/deck.gl-healpix/src/utils/resolve-frame.test.ts` + +- [ ] **Step 1: Add failing tests** + +Add tests for the default scalar `colorMode`, explicit `colorMode`, default unbounded filtering, explicit root ranges, and frame overrides in `resolve-frame.test.ts`: + +```ts +it('defaults filter range to unbounded and rescale range to min/max', () => { + const result = resolveFrame(makeProps({ min: -2, max: 8 })); + expect(result.filterMin).toBe(-Infinity); + expect(result.filterMax).toBe(Infinity); + expect(result.rescaleMin).toBe(-2); + expect(result.rescaleMax).toBe(8); +}); + +it('uses explicit root filter and rescale ranges', () => { + const result = resolveFrame( + makeProps({ + filterMin: 0.2, + filterMax: 0.9, + rescaleMin: 0.1, + rescaleMax: 1.2 + }) + ); + expect(result.filterMin).toBe(0.2); + expect(result.filterMax).toBe(0.9); + expect(result.rescaleMin).toBe(0.1); + expect(result.rescaleMax).toBe(1.2); +}); +``` + +- [ ] **Step 2: Run the focused test and verify it fails** + +Run: + +```bash +npm test --workspace @developmentseed/deck.gl-healpix -- src/utils/resolve-frame.test.ts +``` + +Expected: fails because the new properties are not typed or resolved yet. + +- [ ] **Step 3: Add prop and resolved-frame types** + +Define and export: + +```ts +export const HEALPIX_COLOR_MODE_SCALAR = 1; +export const HEALPIX_COLOR_MODE_SCALAR_ALPHA = 2; +export const HEALPIX_COLOR_MODE_RGB = 3; +export const HEALPIX_COLOR_MODE_RGBA = 4; + +export type HealpixColorMode = + | typeof HEALPIX_COLOR_MODE_SCALAR + | typeof HEALPIX_COLOR_MODE_SCALAR_ALPHA + | typeof HEALPIX_COLOR_MODE_RGB + | typeof HEALPIX_COLOR_MODE_RGBA; +``` + +Add to both public prop types: + +```ts + colorMode?: HealpixColorMode; + filterMin?: number; + filterMax?: number; + rescaleMin?: number; + rescaleMax?: number; +``` + +Add root-only `shaderModules` to `HealpixCellsLayerProps`: + +```ts + /** Custom shader modules appended after the built-in HEALPix color pipeline. */ + shaderModules?: ShaderModule[]; +``` + +Import `ShaderModule` as a type from `@luma.gl/shadertools`. + +Add `colorMode` as a required resolved field and add the range fields as required numbers to `ResolvedFrame`. + +- [ ] **Step 4: Resolve values** + +Add to the `resolveFrame()` returned object: + +```ts + colorMode: + frame.colorMode ?? props.colorMode ?? HEALPIX_COLOR_MODE_SCALAR, + filterMin: frame.filterMin ?? props.filterMin ?? -Infinity, + filterMax: frame.filterMax ?? props.filterMax ?? Infinity, + rescaleMin: + frame.rescaleMin ?? props.rescaleMin ?? frame.min ?? props.min ?? 0, + rescaleMax: + frame.rescaleMax ?? props.rescaleMax ?? frame.max ?? props.max ?? 1, +``` + +- [ ] **Step 5: Run the focused test and verify it passes** + +Run: + +```bash +npm test --workspace @developmentseed/deck.gl-healpix -- src/utils/resolve-frame.test.ts +``` + +Expected: all tests in `resolve-frame.test.ts` pass. + +--- + +### Task 2: Pack More Than Four Values Per Cell + +**Files:** +- Modify: `packages/deck.gl-healpix/src/utils/values-texture.ts` +- Test: `packages/deck.gl-healpix/src/utils/values-texture.test.ts` + +- [ ] **Step 1: Add failing packing tests** + +Add tests that use `dimensions = 5` and assert that one cell occupies two RGBA texels, with the fifth value in the second texel's red channel. Also cover `dimensions = 10` to confirm `ceil(dimensions / 4)` texels per cell. + +- [ ] **Step 2: Update packing metadata** + +Return `texelsPerCell` from the values texture packing utility and compute: + +```ts +const texelsPerCell = Math.ceil(dimensions / 4); +const texelCount = cellCount * texelsPerCell; +``` + +- [ ] **Step 3: Run values texture tests** + +Run: + +```bash +npm test --workspace @developmentseed/deck.gl-healpix -- src/utils/values-texture.test.ts +``` + +Expected: values texture tests pass. + +--- + +### Task 3: Thread Color Mode, Ranges, And Packing To The Primitive Layer + +**Files:** +- Modify: `packages/deck.gl-healpix/src/layers/healpix-cells-layer.ts` +- Modify: `packages/deck.gl-healpix/src/layers/healpix-cells-primitive-layer.ts` + +- [ ] **Step 1: Stop appending the old color extension** + +Remove the `HEALPIX_COLOR_EXTENSION` import and pass only user-provided extensions: + +```ts +extensions: (this.props.extensions as LayerExtension[]) || [] +``` + +- [ ] **Step 2: Pass resolved values and packing metadata to the primitive layer** + +Pass: + +```ts +uFilterMin: filterMin, +uFilterMax: filterMax, +uRescaleMin: rescaleMin, +uRescaleMax: rescaleMax, +uDimensions: dimensions, +uColorMode: colorMode, +uValuesWidth: valuesTextureWidth, +uTexelsPerCell: valuesTexelsPerCell, +shaderModules: this.props.shaderModules ?? [], +``` + +Remove old `uMin` and `uMax`. + +- [ ] **Step 3: Add primitive pipeline prop type** + +Add: + +```ts +type HealpixColorPipelineProps = { + valuesTexture: Texture; + colorMapTexture: Texture; + uFilterMin: number; + uFilterMax: number; + uRescaleMin: number; + uRescaleMax: number; + uDimensions: number; + uColorMode: number; + uValuesWidth: number; + uTexelsPerCell: number; + shaderModules: ShaderModule[]; +}; +``` + +Use it in `HealpixCellsPrimitiveLayerMergedProps`. + +--- + +### Task 4: Add Injecting Shader Modules + +**Files:** +- Create: `packages/deck.gl-healpix/src/shaders/healpix-values-shader-module.ts` +- Create: `packages/deck.gl-healpix/src/shaders/healpix-filter-shader-module.ts` +- Create: `packages/deck.gl-healpix/src/shaders/healpix-rescale-shader-module.ts` +- Create: `packages/deck.gl-healpix/src/shaders/healpix-color-shader-module.ts` + +- [ ] **Step 1: Create the values module** + +Create a module named `healpixValues` with `healpixValuesTexture`, `uDimensions`, `uColorMode`, `uValuesWidth`, and `uTexelsPerCell`. It declares color mode constants and the shared pipeline state: + +```glsl +in float vHealpixCellIndex; + +const int HEALPIX_COLOR_MODE_SCALAR = 1; +const int HEALPIX_COLOR_MODE_SCALAR_ALPHA = 2; +const int HEALPIX_COLOR_MODE_RGB = 3; +const int HEALPIX_COLOR_MODE_RGBA = 4; + +struct HealpixPipelineState { + int cell; + int dimensions; + int colorMode; +}; + +HealpixPipelineState healpixPipeline; +vec4 healpixSelectedValues; +``` + +It defines the low-level accessor `float healpixValueAt(int channel)`. The accessor computes `texel = channel / 4`, `component = channel % 4`, and reads from `cell * uTexelsPerCell + texel`. Its `inject['fs:DECKGL_FILTER_COLOR']` initializes `healpixPipeline.cell`, `dimensions`, `colorMode`, and `healpixSelectedValues` from the first four selected values according to `dimensions`, then calls: + +```glsl +HEALPIX_SELECT_VALUES(healpixSelectedValues, geometry); +``` + +`HEALPIX_SELECT_VALUES` is a user hook. By default it does nothing. User shader modules can inject into it to rewrite `healpixSelectedValues`, call `healpixValueAt(channel)`, and discard fragments before built-in filtering. + +- [ ] **Step 2: Create the filter module** + +Create a module named `healpixFilter` with `uFilterMin/uFilterMax`. Its injection reads `healpixSelectedValues.x` and calls `discard` when `colorMode` is `HEALPIX_COLOR_MODE_SCALAR` or `HEALPIX_COLOR_MODE_SCALAR_ALPHA` and the selected value is outside range. + +- [ ] **Step 3: Create the rescale module** + +Create a module named `healpixRescale` with `uRescaleMin/uRescaleMax`. Its injection overwrites `healpixSelectedValues.x` with the rescaled value for scalar color modes, then calls: + +```glsl +HEALPIX_RESCALE_VALUES(healpixSelectedValues, geometry); +``` + +`HEALPIX_RESCALE_VALUES` is a user hook. By default it does nothing. User shader modules can inject into it to apply custom nonlinear or multi-channel rescaling. + +- [ ] **Step 4: Create the color module** + +Create a module named `healpixColor` with `healpixColorMapTexture`. Its injection assigns the hook `color` parameter according to `colorMode`, using `healpixSelectedValues`, and applies `layer.opacity`. + +Register `HEALPIX_SELECT_VALUES` and `HEALPIX_RESCALE_VALUES` on `context.shaderAssembler` before creating the model. Do not define public stage functions such as `healpixApplyColor()` or make the fragment shader call stage functions. `healpixValueAt(channel)` is allowed because it is the values module's accessor primitive, not a stage invocation. + +--- + +### Task 5: Compose Modules In The Primitive Layer + +**Files:** +- Modify: `packages/deck.gl-healpix/src/layers/healpix-cells-primitive-layer.ts` +- Modify: `packages/deck.gl-healpix/src/shaders/healpix-cells.vs.glsl.ts` +- Modify: `packages/deck.gl-healpix/src/shaders/healpix-cells.fs.glsl.ts` +- Test: `packages/deck.gl-healpix/src/shaders/healpix-color-pipeline.test.ts` + +- [ ] **Step 1: Add shader structure tests** + +Tests should assert that each stage module injects into `fs:DECKGL_FILTER_COLOR`, that the fragment shader does not call stage functions, that `healpixValueAt` and `healpixSelectedValues` exist in the values module, that the selector/rescale hooks are present, that `this.props.shaderModules` is appended after built-in modules, and that removing the filter module would not leave an unresolved filter function call. + +- [ ] **Step 2: Register `healpixCellIndex`** + +Register `healpixCellIndex` in `HealpixCellsPrimitiveLayer.initializeState()` alongside `cellIdLo` and `cellIdHi`. + +- [ ] **Step 3: Compose modules in dependency order** + +Use: + +```ts +modules: [ + project32, + picking, + healpixCellsShaderModule, + healpixValuesShaderModule, + healpixFilterShaderModule, + healpixRescaleShaderModule, + healpixColorShaderModule, + ...this.props.shaderModules +] +``` + +- [ ] **Step 4: Pass cell index to the fragment shader** + +Add `in float healpixCellIndex;` and `out float vHealpixCellIndex;` in the vertex shader and assign: + +```glsl +vHealpixCellIndex = healpixCellIndex; +``` + +- [ ] **Step 5: Keep the fragment shader as hook host** + +Keep fragment `main()` minimal: + +```glsl +void main() { + fragColor = vColor; + DECKGL_FILTER_COLOR(fragColor, geometry); +} +``` + +- [ ] **Step 6: Bind shader inputs in `draw()`** + +Set props for `healpixValues`, `healpixFilter`, `healpixRescale`, and `healpixColor` through `model.shaderInputs.setProps()`. + +- [ ] **Step 7: Run focused tests and typecheck** + +Run: + +```bash +npm test --workspace @developmentseed/deck.gl-healpix -- src/shaders/healpix-color-pipeline.test.ts src/utils/resolve-frame.test.ts +npm run ts-check --workspace @developmentseed/deck.gl-healpix +``` + +Expected: both commands pass. + +--- + +### Task 6: Remove The Old Color Extension + +**Files:** +- Delete: `packages/deck.gl-healpix/src/extensions/healpix-color-extension.ts` +- Delete: `packages/deck.gl-healpix/src/extensions/healpix-color-shader-module.ts` +- Modify: `packages/deck.gl-healpix/src/index.ts` + +- [ ] **Step 1: Check for stale imports** + +Run: + +```bash +rg "healpix-color|HealpixColor|HEALPIX_COLOR" packages/deck.gl-healpix/src +``` + +Expected: only old extension files and stale public exports match. + +- [ ] **Step 2: Delete stale files and exports** + +Delete the old extension files and remove any color-extension exports from `index.ts`. + +- [ ] **Step 3: Run typecheck** + +Run: + +```bash +npm run ts-check --workspace @developmentseed/deck.gl-healpix +``` + +Expected: passes. + +--- + +### Task 7: Update The Sandbox Smoke Controls + +**Files:** +- Modify: `examples/sandbox/app/pages/color/index.tsx` + +- [ ] **Step 1: Rename scalar range state** + +Replace `indexMin/indexMax` with `rescaleMin/rescaleMax` and add `filterMin/filterMax`. + +- [ ] **Step 2: Pass new props to scalar layers** + +For NDVI and single-band layers, pass: + +```ts +rescaleMin, +rescaleMax, +filterMin, +filterMax, +``` + +- [ ] **Step 3: Add filter smoke control** + +Add a second scalar-only slider labelled `Visibility filter`. It updates `filterMin/filterMax`; the existing slider updates `rescaleMin/rescaleMax`. + +- [ ] **Step 4: Run package verification** + +Run: + +```bash +npm test --workspace @developmentseed/deck.gl-healpix +npm run ts-check --workspace @developmentseed/deck.gl-healpix +npm run build --workspace @developmentseed/deck.gl-healpix +``` + +Expected: all commands pass. + +--- + +### Task 8: Manual Smoke Test + +**Files:** +- No edits unless verification reveals a problem. + +- [ ] **Step 1: Verify scalar filtering** + +Run the sandbox color page, choose `NDVI`, set a narrow visibility filter, and confirm cells outside the range disappear rather than becoming transparent. + +- [ ] **Step 2: Verify rescale independence** + +Keep the filter range fixed, change the rescale range, and confirm colors change while the visible set remains the same. + +- [ ] **Step 3: Verify direct RGB modes** + +Choose `True color` or another RGB composite and confirm filter/rescale controls do not affect direct RGB rendering. \ No newline at end of file diff --git a/docs/superpowers/specs/2026-04-28-healpix-cell-color-pipeline-design.md b/docs/superpowers/specs/2026-04-28-healpix-cell-color-pipeline-design.md new file mode 100644 index 0000000..be30186 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-healpix-cell-color-pipeline-design.md @@ -0,0 +1,624 @@ +# HEALPix Cell Color Pipeline - Design Spec + +**Date:** 2026-04-28 +**Branch:** `feature/healpix-sentinel-process` +**Status:** Approved + +--- + +## Overview + +Refactor the `HealpixCellsLayer` color path into a small GPU pipeline that keeps the existing cell calculation intact while making filtering, rescaling, and coloring explicit stages. + +The current implementation isolates color work in one `HealpixColorExtension` block injected into the vertex shader. The new design removes that single color extension from the core pipeline and composes several luma/deck shader modules directly in `HealpixCellsPrimitiveLayer.getShaders()`. User-provided modules can be appended through a `shaderModules` prop. + +```ts +modules: [ + project32, + picking, + healpixCellsShaderModule, + healpixValuesShaderModule, + healpixFilterShaderModule, + healpixRescaleShaderModule, + healpixColorShaderModule +] +``` + +The fragment shader invokes those modules in order: + +``` +cell reference + -> selected values vec4 + -> custom value selector hook + -> immediate filter/discard + -> rescale selected scalar + -> custom rescale hook + -> color variable + -> deck.gl opacity/picking hooks +``` + +This makes the GPU pipeline explicit without asking users to manually compose deck.gl extensions. Each built-in shader module owns one stage, and user modules can target the public hook points. + +--- + +## Goals + +- Keep HEALPix cell decode/corner calculation unchanged. +- Filter cells by their first value before final fragment output. +- Discard filtered cells completely, including picking output. +- Rescale scalar values independently from the filter range. +- Use `dimensions` only for the number of source values stored per cell. +- Use `colorMode` to control how selected values are filtered, rescaled, and colored. +- Allow `healpixValueAt(channel)` to address source channels beyond `0..3`. + +## Non-goals + +- Implement built-in band math presets in this change. +- Split the public API into multiple user-facing extensions. +- Keep `HealpixColorExtension` as the internal color-pipeline owner. +- Change HEALPix geometry, NEST/RING decode, or corner precision. + +--- + +## Public API + +Add explicit filter and rescale ranges to both root layer props and frame objects: + +```ts +export const HEALPIX_COLOR_MODE_SCALAR = 1; +export const HEALPIX_COLOR_MODE_SCALAR_ALPHA = 2; +export const HEALPIX_COLOR_MODE_RGB = 3; +export const HEALPIX_COLOR_MODE_RGBA = 4; + +export type HealpixColorMode = + | typeof HEALPIX_COLOR_MODE_SCALAR + | typeof HEALPIX_COLOR_MODE_SCALAR_ALPHA + | typeof HEALPIX_COLOR_MODE_RGB + | typeof HEALPIX_COLOR_MODE_RGBA; + +type HealpixFrameObject = { + // existing fields... + colorMode?: HealpixColorMode + filterMin?: number + filterMax?: number + rescaleMin?: number + rescaleMax?: number +} + +type HealpixCellsLayerProps = { + // existing fields... + colorMode?: HealpixColorMode + filterMin?: number + filterMax?: number + rescaleMin?: number + rescaleMax?: number + shaderModules?: ShaderModule[] +} & CompositeLayerProps +``` + +`shaderModules` is a root-level layer prop, not a frame prop. It appends custom luma shader modules to the primitive layer after the built-in pipeline modules. Custom modules can inject into the public hooks registered by the primitive layer: + +```ts +inject: { + 'fs:HEALPIX_SELECT_VALUES': `...`, + 'fs:HEALPIX_RESCALE_VALUES': `...` +} +``` + +Defaults: + +```ts +colorMode = HEALPIX_COLOR_MODE_SCALAR +filterMin = -Infinity +filterMax = Infinity +rescaleMin = min ?? 0 +rescaleMax = max ?? 1 +``` + +`min` and `max` stay as compatibility aliases for the scalar color range, but the clearer names are `rescaleMin` and `rescaleMax`. New code should use the rescale props. `colorMode` does not infer from `dimensions`; callers must set it explicitly with the exported integer constants for scalar-alpha, RGB, or RGBA rendering. + +### Custom Selector Example + +```ts +const ndviSelectorModule = { + name: 'ndviSelector', + inject: { + 'fs:HEALPIX_SELECT_VALUES': `\ +float nir = healpixValueAt(7); +float red = healpixValueAt(3); +float denom = max(nir + red, 1e-6); +float ndvi = (nir - red) / denom; + +if (ndvi < 0.2) { + discard; +} + +healpixSelectedValues = vec4(ndvi, 0.0, 0.0, 0.0); +` + } +}; + +new HealpixCellsLayer({ + id: 'ndvi', + nside, + cellIds, + values, + dimensions: bandCount, + colorMode: HEALPIX_COLOR_MODE_SCALAR, + rescaleMin: -1, + rescaleMax: 1, + shaderModules: [ndviSelectorModule] +}); +``` + +### Custom Rescale Example + +```ts +const gammaRescaleModule = { + name: 'gammaRescale', + inject: { + 'fs:HEALPIX_RESCALE_VALUES': `\ +healpixSelectedValues.x = pow(healpixSelectedValues.x, 0.5); +` + } +}; +``` + +Frame resolution follows the existing root-defaults/frame-overrides model: + +``` +effectiveFrame = { + // existing fields... + colorMode: frame.colorMode ?? props.colorMode ?? HEALPIX_COLOR_MODE_SCALAR, + filterMin: frame.filterMin ?? props.filterMin ?? -Infinity, + filterMax: frame.filterMax ?? props.filterMax ?? Infinity, + rescaleMin: frame.rescaleMin ?? props.rescaleMin ?? frame.min ?? props.min ?? 0, + rescaleMax: frame.rescaleMax ?? props.rescaleMax ?? frame.max ?? props.max ?? 1, +} +``` + +--- + +## Dimensions And Color Mode Behavior + +`dimensions` is the number of source values stored per cell. It controls value texture packing and the valid channel range for `healpixValueAt(channel)`. + +`colorMode` controls how `healpixSelectedValues` is interpreted after the selector hook runs. + +```mermaid +flowchart TD + A["Layer props
values, dimensions, colorMode"] --> B["resolveFrame()
defaults colorMode to HEALPIX_COLOR_MODE_SCALAR"] + B --> C["packValuesData()
texelsPerCell = ceil(dimensions / 4)"] + C --> D["healpixValuesTexture
raw channels packed as RGBA texels"] + D --> E["healpixValues module
sets healpixDimensions and healpixColorMode"] + E --> F["healpixValueAt(channel)
reads any channel below dimensions"] + F --> G["Default selection
healpixSelectedValues = channels 0..3"] + G --> H["fs:HEALPIX_SELECT_VALUES
user may replace selected values"] + H --> I{"colorMode"} + I -->|"SCALAR or SCALAR_ALPHA"| J["healpixFilter
filterMin/filterMax on selectedValues.x"] + J --> K["healpixRescale
rescale selectedValues.x"] + K --> L["fs:HEALPIX_RESCALE_VALUES
user may adjust rescaled values"] + L --> M["healpixColor
colorMap lookup, optional alpha"] + I -->|"RGB or RGBA"| N["healpixColor
direct selected RGB/RGBA"] + M --> O["Fragment color"] + N --> O +``` + +### `colorMode = HEALPIX_COLOR_MODE_SCALAR` + +``` +valueAt(0) + -> filterMin/filterMax visibility gate + -> normalize through rescaleMin/rescaleMax + -> colorMap lookup +``` + +### `colorMode = HEALPIX_COLOR_MODE_SCALAR_ALPHA` + +``` +valueAt(0) + -> filterMin/filterMax visibility gate + -> normalize through rescaleMin/rescaleMax + -> colorMap lookup + +valueAt(1) + -> alpha multiplier +``` + +### `colorMode = HEALPIX_COLOR_MODE_RGB` + +No filter or rescale. Values are direct RGB: + +``` +vec4(valueAt(0), valueAt(1), valueAt(2), 1.0) +``` + +### `colorMode = HEALPIX_COLOR_MODE_RGBA` + +No filter or rescale. Values are direct RGBA: + +``` +vec4(valueAt(0), valueAt(1), valueAt(2), valueAt(3)) +``` + +Invalid or unsupported `colorMode` values keep fail-soft behavior: render transparent rather than throwing during draw. + +--- + +## Shader Architecture + +Use several shader modules passed to `getShaders({ modules })`. The primitive layer owns the pipeline assembly and draw-time shader inputs. Modules act by injecting code into shader hook points, not by defining public functions that the final shader must call. + +```ts +getShaders(): ReturnType { + return super.getShaders({ + vs: HEALPIX_VERTEX_SHADER, + fs: HEALPIX_FRAGMENT_SHADER, + modules: [ + project32, + picking, + healpixCellsShaderModule, + healpixValuesShaderModule, + healpixFilterShaderModule, + healpixRescaleShaderModule, + healpixColorShaderModule, + ...this.props.shaderModules + ] + }); +} +``` + +The fragment shader should stay small. It should not call stage functions such as filtering, rescaling, or color application directly. Instead it exposes a deck.gl color hook, and modules inject their stage code into that hook: + +```glsl +void main() { + fragColor = vColor; + DECKGL_FILTER_COLOR(fragColor, geometry); +} +``` + +The shared state is a selected `vec4` plus small metadata declared by the required values module. Optional stage modules mutate `healpixSelectedValues` or `color` inside the hook. Removing `healpixFilterShaderModule` removes filtering and should not break compilation. + +### `healpixValuesShaderModule` + +Responsible for shared pipeline context, raw value access, and default selected-value loading. No new texture is created per cell; the texture is the existing uploaded `healpixValuesTexture`. + +This module provides `healpixValueAt(channel)` as the low-level value accessor. That accessor is different from a stage function: the base fragment shader does not call it, and removing an optional stage such as filtering does not leave an unresolved call. Stages that need values can call `healpixValueAt()`. + +The module also declares the working value used by downstream modules: + +```glsl +vec4 healpixSelectedValues; +``` + +Declarations: + +```glsl +const int HEALPIX_COLOR_MODE_SCALAR = 1; +const int HEALPIX_COLOR_MODE_SCALAR_ALPHA = 2; +const int HEALPIX_COLOR_MODE_RGB = 3; +const int HEALPIX_COLOR_MODE_RGBA = 4; + +struct HealpixPipelineState { + int cell; + int dimensions; + int colorMode; +}; + +HealpixPipelineState healpixPipeline; +vec4 healpixSelectedValues; + +float healpixValueAt(int channel) { + int texel = channel / 4; + int component = channel - texel * 4; + + int valueIndex = healpixPipeline.cell * healpixValues.uTexelsPerCell + texel; + int x = valueIndex % healpixValues.uValuesWidth; + int y = valueIndex / healpixValues.uValuesWidth; + vec4 rgba = texelFetch(healpixValuesTexture, ivec2(x, y), 0); + + if (component == 0) return rgba.r; + if (component == 1) return rgba.g; + if (component == 2) return rgba.b; + if (component == 3) return rgba.a; + return 0.0; +} +``` + +`uTexelsPerCell = ceil(dimensions / 4)`, so `healpixValueAt(channel)` can address any source value channel packed into the texture. Channels outside the declared `dimensions` range return `0.0`. + +Hook injection: + +```glsl +healpixPipeline.cell = int(vHealpixCellIndex + 0.5); +healpixPipeline.dimensions = healpixValues.uDimensions; +healpixPipeline.colorMode = healpixValues.uColorMode; +healpixSelectedValues = vec4( + healpixValueAt(0), + healpixPipeline.dimensions > 1 ? healpixValueAt(1) : 0.0, + healpixPipeline.dimensions > 2 ? healpixValueAt(2) : 0.0, + healpixPipeline.dimensions > 3 ? healpixValueAt(3) : 0.0 +); +``` + +`HEALPIX_SELECT_VALUES` is a user hook. By default it does nothing. A user module passed through `shaderModules` may inject into this hook to rewrite `healpixSelectedValues` using `healpixValueAt(channel)` and may call `discard` for custom filtering before the built-in filter stage runs. + +Module-owned inputs: + +- `healpixValuesTexture` +- `uDimensions` +- `uColorMode` +- `uValuesWidth` +- `uTexelsPerCell` + +### `healpixFilterShaderModule` + +Responsible for the visibility gate. It acts on the shared pipeline context and discards immediately when the cell is outside the filter range: + +```glsl +if ( + healpixPipeline.colorMode == HEALPIX_COLOR_MODE_SCALAR || + healpixPipeline.colorMode == HEALPIX_COLOR_MODE_SCALAR_ALPHA +) { + float v = healpixSelectedValues.x; + if (v < healpixFilter.uFilterMin || v > healpixFilter.uFilterMax) { + discard; + } +} +``` + +There is no stored `visible` state. A rejected fragment exits the pipeline at this stage. + +Module-owned inputs: + +- `uFilterMin` +- `uFilterMax` + +### `healpixRescaleShaderModule` + +Responsible for scalar normalization. It overwrites `healpixSelectedValues.x` with the rescaled value for scalar color modes. For RGB and RGBA color modes, the color stage uses `healpixSelectedValues` directly. + +```glsl +if ( + healpixPipeline.colorMode == HEALPIX_COLOR_MODE_SCALAR || + healpixPipeline.colorMode == HEALPIX_COLOR_MODE_SCALAR_ALPHA +) { + float value = healpixSelectedValues.x; + float denom = healpixRescale.uRescaleMax - healpixRescale.uRescaleMin; + healpixSelectedValues.x = denom == 0.0 + ? 0.0 + : clamp((value - healpixRescale.uRescaleMin) / denom, 0.0, 1.0); +} +``` + +This preserves the current zero-width-range behavior. + +`HEALPIX_RESCALE_VALUES` is a user hook. By default it does nothing. A user module passed through `shaderModules` may inject into it to apply nonlinear stretches or multi-channel rescaling after the built-in rescale stage. + +Module-owned inputs: + +- `uRescaleMin` +- `uRescaleMax` + +### `healpixColorShaderModule` + +Responsible for assigning the `color` hook parameter: + +```glsl +if (healpixPipeline.colorMode == HEALPIX_COLOR_MODE_SCALAR) { + color = texelFetch( + healpixColorMapTexture, + ivec2(int(healpixSelectedValues.x * 255.0), 0), + 0 + ); +} else if (healpixPipeline.colorMode == HEALPIX_COLOR_MODE_SCALAR_ALPHA) { + color = texelFetch( + healpixColorMapTexture, + ivec2(int(healpixSelectedValues.x * 255.0), 0), + 0 + ); + color.a *= healpixSelectedValues.y; +} else if (healpixPipeline.colorMode == HEALPIX_COLOR_MODE_RGB) { + color = vec4(healpixSelectedValues.rgb, 1.0); +} else if (healpixPipeline.colorMode == HEALPIX_COLOR_MODE_RGBA) { + color = healpixSelectedValues; +} else { + color = vec4(0.0); +} +``` + +Module-owned inputs: + +- `healpixColorMapTexture` + +### Module Independence And Order + +The modules are listed in execution order: + +``` +healpixValues +healpixFilter +healpixRescale +healpixColor +``` + +`healpixValuesShaderModule` is required for the built-in value-driven color pipeline. The remaining stage modules should be removable when their behavior is not desired: + +- Remove `healpixFilterShaderModule`: no filtering happens, and compilation still succeeds. +- Remove `healpixRescaleShaderModule`: `healpixSelectedValues.x` remains the selected first value, so scalar color uses the selected raw value as the colorMap coordinate. +- Remove `healpixColorShaderModule`: the shader keeps whatever color was set before the hook. + +Keep the order explicit in `HealpixCellsPrimitiveLayer.getShaders()` and cover it with shader structure tests. Do not rely on implicit extension ordering for the built-in pipeline. + +--- + +## Vertex vs Fragment Responsibilities + +The current color extension injects into `vs:DECKGL_FILTER_COLOR`, so color is computed in the vertex shader and interpolated across each cell quad. That works today because every vertex of a cell reads the same cell value, producing a solid color. + +The new filter requirement changes the stage split. GLSL `discard` is only valid in fragment shaders, so a vertex shader cannot truly remove a cell. It can only output transparent color or move geometry away, neither of which is the desired behavior. + +New stage responsibilities: + +``` +vertex shader: + decode HEALPix cell + position quad corners + pass healpixCellIndex to the fragment shader + +fragment shader: + create value accessor for the cell + discard if the scalar filter rejects the cell + rescale scalar values + compute final color + apply layer opacity and deck.gl output hooks +``` + +This means values are fetched per fragment instead of per vertex. For this layer, the cost is acceptable because it gives correct discard semantics, keeps filter/rescale/color in one place, and avoids passing a fixed set of value varyings that would get in the way of future `dimensions > 4` support. + +--- + +## Layer and Module Wiring + +`HealpixColorExtension` should no longer be appended by `HealpixCellsLayer` for the built-in color path. The primitive layer owns the color pipeline because the modules are part of the layer's base shaders. `HealpixCellsLayer` forwards `shaderModules` to the primitive layer. + +### Attributes + +Move `healpixCellIndex` registration into `HealpixCellsPrimitiveLayer.initializeState()` next to `cellIdLo` and `cellIdHi`: + +```ts +healpixCellIndex: { + size: 1, + type: 'float32', + stepMode: 'instance', + accessor: 'healpixCellIndex', + defaultValue: 0, + noAlloc: true +} +``` + +The vertex shader declares: + +```glsl +in float healpixCellIndex; +out float vHealpixCellIndex; +``` + +and assigns: + +```glsl +vHealpixCellIndex = healpixCellIndex; +``` + +### Shader inputs + +`HealpixCellsPrimitiveLayer.draw()` sets props for each module: + +```ts +model.shaderInputs.setProps({ + healpixCells: computeHealpixCellsUniforms(this.props.nside, this.props.scheme), + healpixValues: { + uDimensions, + uColorMode, + uValuesWidth, + uTexelsPerCell, + healpixValuesTexture: valuesTexture + }, + healpixFilter: { + uFilterMin, + uFilterMax + }, + healpixRescale: { + uRescaleMin, + uRescaleMax + }, + healpixColor: { + healpixColorMapTexture: colorMapTexture + } +}); +``` + +### User Module Ordering + +`shaderModules` are appended after the built-in modules in `getShaders()`. Their injections target public hook names registered on deck's shader assembler by `HealpixCellsPrimitiveLayer`: + +- `fs:HEALPIX_SELECT_VALUES` is called inside `healpixValuesShaderModule`, after default selected values are loaded and before built-in filtering. +- `fs:HEALPIX_RESCALE_VALUES` is called inside `healpixRescaleShaderModule`, after built-in scalar rescaling and before coloring. + +This lets user modules customize selection/rescale behavior without replacing the whole pipeline. + +`uMin` and `uMax` can remain public compatibility aliases only at the frame-resolution layer. The shader modules should use `uRescaleMin` and `uRescaleMax`. + +--- + +## Picking Behavior + +Filtered cells must be discarded completely. The discard should run in the fragment shader before final color output in both normal and picking passes. + +The implementation must preserve deck.gl's picking machinery. The extension should not bypass picking color output; it should only discard rejected fragments before deck.gl writes final pass-specific output. + +Acceptance criterion: a filtered-out cell is not visible and cannot be picked. + +--- + +## Testing + +### Unit tests + +- `resolve-frame.test.ts` + - root defaults for `filterMin/filterMax/rescaleMin/rescaleMax` + - frame overrides for each new range prop + - `rescaleMin/rescaleMax` fallback to existing `min/max` + +- `values-texture.test.ts` + - current packing still supports channels `0..3` + - value-accessor assumptions are documented by tests even though the accessor itself is GLSL + +### Shader structure tests + +Add tests that assert shader/module strings and shader assembly contain the required pieces: + +- each stage module injects into `fs:DECKGL_FILTER_COLOR` +- `healpixFilterShaderModule` contains fragment-stage `discard` +- `healpixColorShaderModule` assigns the hook `color` parameter +- `HealpixCellsPrimitiveLayer.getShaders()` includes modules in the intended order +- the fragment shader stays a hook host and does not call stage functions directly + +These tests do not prove GPU output, but they catch accidental regression back to one inline vertex-color block or a function-calling pipeline. + +### Manual smoke test + +Update or add a sandbox color example with a visible filter window: + +- `colorMode = HEALPIX_COLOR_MODE_SCALAR`: cells outside `filterMin/filterMax` disappear +- `colorMode = HEALPIX_COLOR_MODE_SCALAR_ALPHA`: same visibility behavior, with selected channel `1` still affecting alpha +- picking a filtered-out cell returns nothing +- changing `rescaleMin/rescaleMax` changes colors without changing visibility + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| Fragment-stage value fetch costs more than vertex-stage fetch | Accept for correctness first; profile later if large cells/fill rate become a problem. | +| Deck.gl picking hooks conflict with custom fragment output | Keep `DECKGL_FILTER_COLOR(fragColor, geometry)` in the fragment shader after module color assignment and verify both render and picking passes manually. | +| `Infinity` uniforms behave inconsistently across backends | If needed, map unbounded filter defaults to large finite sentinels before uniform upload. | +| Multi-texel value layout leaks into filter/color code | Keep texture-layout knowledge inside `healpixValueAt()` and pass only selected values plus `colorMode` downstream. | +| Shader module ordering becomes implicit | Keep the module list in dependency order in `getShaders()` and add tests that assert the order. | + +--- + +## Implementation Outline + +1. Extend layer and frame props with `colorMode` and filter/rescale ranges. +2. Update frame resolution defaults and tests. +3. Move `healpixCellIndex` ownership from `HealpixColorExtension` into `HealpixCellsPrimitiveLayer`. +4. Update value texture packing so `dimensions` can exceed four values per cell. +5. Add `healpix-values`, `healpix-filter`, `healpix-rescale`, and `healpix-color` shader modules. +6. Compose those modules in `HealpixCellsPrimitiveLayer.getShaders()`. +7. Update the fragment shader to invoke the module pipeline in order. +8. Bind each module's uniforms/textures from `HealpixCellsPrimitiveLayer.draw()`. +9. Remove the built-in dependency on `HealpixColorExtension`. +10. Add shader structure/order tests. +11. Update the sandbox color example for filter/rescale smoke testing. From 937a8e99bca5bbcbdcb0b2e63a81b77a54fa0924 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 5 May 2026 16:15:19 +0100 Subject: [PATCH 02/24] Define new color pipeline API properties and types --- .../deck.gl-healpix/src/types/layer-props.ts | 84 ++++++++++++++----- 1 file changed, 63 insertions(+), 21 deletions(-) diff --git a/packages/deck.gl-healpix/src/types/layer-props.ts b/packages/deck.gl-healpix/src/types/layer-props.ts index ad874da..cad9e30 100644 --- a/packages/deck.gl-healpix/src/types/layer-props.ts +++ b/packages/deck.gl-healpix/src/types/layer-props.ts @@ -1,9 +1,21 @@ import type { CompositeLayerProps } from '@deck.gl/core'; +import type { ShaderModule } from '@luma.gl/shadertools'; import type { CellIdArray } from './cell-ids'; /** HEALPix pixel numbering scheme. */ export type HealpixScheme = 'nest' | 'ring'; +export const HEALPIX_COLOR_MODE_SCALAR = 1; +export const HEALPIX_COLOR_MODE_SCALAR_ALPHA = 2; +export const HEALPIX_COLOR_MODE_RGB = 3; +export const HEALPIX_COLOR_MODE_RGBA = 4; + +export type HealpixColorMode = + | typeof HEALPIX_COLOR_MODE_SCALAR + | typeof HEALPIX_COLOR_MODE_SCALAR_ALPHA + | typeof HEALPIX_COLOR_MODE_RGB + | typeof HEALPIX_COLOR_MODE_RGBA; + export type { CellIdArray }; /** @@ -20,17 +32,8 @@ export type { CellIdArray }; * `values` is an interleaved flat array. Cell `i` occupies indices * `i * dimensions` through `i * dimensions + dimensions - 1`. * - * ## `dimensions` interpretation - * - * | `dimensions` | Interpretation | - * |---|---| - * | `1` | Scalar → normalized through `[min, max]` → colorMap LUT → RGBA | - * | `2` | Scalar (→ colorMap) + opacity multiplier (0–1) | - * | `3` | Direct RGB in range 0–1; colorMap/min/max ignored; alpha = 1 | - * | `4` | Direct RGBA in range 0–1; colorMap/min/max ignored | - * - * Values beyond 4 dimensions are reserved for future band math. Cells - * with `dimensions > 4` render as transparent with a console warning. + * `dimensions` is the number of source values per cell. `colorMode` controls + * how selected values are interpreted for rendering. */ export type HealpixFrameObject = { /** Overrides root `nside`. */ @@ -48,11 +51,26 @@ export type HealpixFrameObject = { min?: number; /** Overrides root `max`. Default: `1`. */ max?: number; + /** Render interpretation for selected values. Default: `HEALPIX_COLOR_MODE_SCALAR`. */ + colorMode?: HealpixColorMode; + /** Inclusive lower visibility bound for dimensions 1 and 2. Default: unbounded. */ + filterMin?: number; + /** Inclusive upper visibility bound for dimensions 1 and 2. Default: unbounded. */ + filterMax?: number; + /** + * Value mapped to colorMap index 0 for dimensions 1 and 2. + * Defaults to `min` for backwards compatibility, then `0`. + */ + rescaleMin?: number; + /** + * Value mapped to colorMap index 255 for dimensions 1 and 2. + * Defaults to `max` for backwards compatibility, then `1`. + */ + rescaleMax?: number; /** - * Number of values per cell. Controls color computation mode. - * Default: `1`. See type-level docs for full interpretation table. + * Number of source values per cell. Default: `1`. */ - dimensions?: 1 | 2 | 3 | 4; + dimensions?: number; /** * ColorMap LUT: exactly 256 × 4 = 1024 RGBA bytes. * Index 0 maps to `min`, index 255 maps to `max`. @@ -90,9 +108,10 @@ export type HealpixFrameObject = { * /> * ``` * - * ## Color dimensions + * ## Color pipeline * - * See `HealpixFrameObject` for the full `dimensions` interpretation table. + * `dimensions` is the number of source values per cell. `colorMode` controls + * how selected values are interpreted for rendering. */ export type HealpixCellsLayerProps = { /** @@ -113,15 +132,36 @@ export type HealpixCellsLayerProps = { * Length must equal `cellIds.length * dimensions`. */ values?: ArrayLike; - /** Value mapped to colorMap index 0. Default: `0`. */ + /** + * Value mapped to colorMap index 0. Default: `0`. + * @deprecated Use `rescaleMin` instead. + */ min?: number; - /** Value mapped to colorMap index 255. Default: `1`. */ + /** + * Value mapped to colorMap index 255. Default: `1`. + * @deprecated Use `rescaleMax` instead. + */ max?: number; + /** Render interpretation for selected values. Default: `HEALPIX_COLOR_MODE_SCALAR`. */ + colorMode?: HealpixColorMode; + /** Inclusive lower visibility bound for dimensions 1 and 2. Default: unbounded. */ + filterMin?: number; + /** Inclusive upper visibility bound for dimensions 1 and 2. Default: unbounded. */ + filterMax?: number; + /** + * Value mapped to colorMap index 0 for dimensions 1 and 2. + * Defaults to `min` for backwards compatibility, then `0`. + */ + rescaleMin?: number; + /** + * Value mapped to colorMap index 255 for dimensions 1 and 2. + * Defaults to `max` for backwards compatibility, then `1`. + */ + rescaleMax?: number; /** - * Number of values per cell. Default: `1`. - * See `HealpixFrameObject` for the full interpretation table. + * Number of source values per cell. Default: `1`. */ - dimensions?: 1 | 2 | 3 | 4; + dimensions?: number; /** * ColorMap LUT: exactly 256 × 4 = 1024 RGBA bytes (default: black→white). * Used as a shared default when frames do not provide their own colorMap. @@ -131,4 +171,6 @@ export type HealpixCellsLayerProps = { frames?: HealpixFrameObject[]; /** Active frame index into `frames`. Clamped to `[0, frames.length - 1]`. Default: `0`. */ currentFrame?: number; + /** Custom shader modules appended after the built-in HEALPix color pipeline. */ + shaderModules?: ShaderModule[]; } & CompositeLayerProps; From 01dc2ced709620ad82527381b0f1ff3dfa62c980 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 5 May 2026 16:15:19 +0100 Subject: [PATCH 03/24] Implement and test frame resolution for new color properties --- .../src/utils/resolve-frame.test.ts | 88 ++++++++++++++++++- .../src/utils/resolve-frame.ts | 25 +++++- 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/packages/deck.gl-healpix/src/utils/resolve-frame.test.ts b/packages/deck.gl-healpix/src/utils/resolve-frame.test.ts index 0d7ec02..f33dec0 100644 --- a/packages/deck.gl-healpix/src/utils/resolve-frame.test.ts +++ b/packages/deck.gl-healpix/src/utils/resolve-frame.test.ts @@ -1,6 +1,11 @@ import { resolveFrame } from './resolve-frame'; import { DEFAULT_COLORMAP } from './color-map'; -import type { HealpixCellsLayerProps } from '../types/layer-props'; +import { + HEALPIX_COLOR_MODE_RGB, + HEALPIX_COLOR_MODE_SCALAR, + HEALPIX_COLOR_MODE_SCALAR_ALPHA, + type HealpixCellsLayerProps +} from '../types/layer-props'; const validIds = new Uint32Array([1, 2, 3]); const validValues = new Float32Array([0.1, 0.2, 0.3]); // dim=1, 3 cells @@ -26,6 +31,7 @@ describe('resolveFrame — single-frame mode (no frames array)', () => { expect(result.min).toBe(0); expect(result.max).toBe(1); expect(result.dimensions).toBe(1); + expect(result.colorMode).toBe(HEALPIX_COLOR_MODE_SCALAR); expect(result.colorMap).toBe(DEFAULT_COLORMAP); }); @@ -37,6 +43,7 @@ describe('resolveFrame — single-frame mode (no frames array)', () => { min: -1, max: 10, dimensions: 3, + colorMode: HEALPIX_COLOR_MODE_RGB, colorMap: myColorMap, values: new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]) // 3 cells × 3 dims }) @@ -45,8 +52,42 @@ describe('resolveFrame — single-frame mode (no frames array)', () => { expect(result.min).toBe(-1); expect(result.max).toBe(10); expect(result.dimensions).toBe(3); + expect(result.colorMode).toBe(HEALPIX_COLOR_MODE_RGB); expect(result.colorMap).toBe(myColorMap); }); + + it('defaults filter range to unbounded and rescale range to min/max', () => { + const result = resolveFrame(makeProps({ min: -2, max: 8 })); + expect(result.filterMin).toBe(-Infinity); + expect(result.filterMax).toBe(Infinity); + expect(result.rescaleMin).toBe(-2); + expect(result.rescaleMax).toBe(8); + }); + + it('uses explicit root filter and rescale ranges', () => { + const result = resolveFrame( + makeProps({ + filterMin: 0.2, + filterMax: 0.9, + rescaleMin: 0.1, + rescaleMax: 1.2 + }) + ); + expect(result.filterMin).toBe(0.2); + expect(result.filterMax).toBe(0.9); + expect(result.rescaleMin).toBe(0.1); + expect(result.rescaleMax).toBe(1.2); + }); + + it('defaults color mode to scalar regardless of dimensions', () => { + const result = resolveFrame( + makeProps({ + dimensions: 5, + values: new Float32Array(15) + }) + ); + expect(result.colorMode).toBe(HEALPIX_COLOR_MODE_SCALAR); + }); }); describe('resolveFrame — multi-frame mode', () => { @@ -77,6 +118,47 @@ describe('resolveFrame — multi-frame mode', () => { expect(result.max).toBe(5); }); + it('frame filter and rescale fields override root props', () => { + const result = resolveFrame( + makeProps({ + filterMin: 0, + filterMax: 1, + rescaleMin: 0, + rescaleMax: 1, + frames: [ + { + values: validValues, + filterMin: 0.25, + filterMax: 0.75, + rescaleMin: -1, + rescaleMax: 2 + } + ], + currentFrame: 0 + }) + ); + expect(result.filterMin).toBe(0.25); + expect(result.filterMax).toBe(0.75); + expect(result.rescaleMin).toBe(-1); + expect(result.rescaleMax).toBe(2); + }); + + it('frame colorMode overrides root colorMode', () => { + const result = resolveFrame( + makeProps({ + colorMode: HEALPIX_COLOR_MODE_RGB, + frames: [ + { + values: validValues, + colorMode: HEALPIX_COLOR_MODE_SCALAR_ALPHA + } + ], + currentFrame: 0 + }) + ); + expect(result.colorMode).toBe(HEALPIX_COLOR_MODE_SCALAR_ALPHA); + }); + it('root props fill gaps not set on frame', () => { const myColorMap = new Uint8Array(1024); const result = resolveFrame( @@ -113,13 +195,13 @@ describe('resolveFrame — validation', () => { resolveFrame({ cellIds: validIds, values: validValues - } as HealpixCellsLayerProps) + } as any) ).toThrow(/nside/); }); it('throws if cellIds is missing', () => { expect(() => - resolveFrame({ nside: 64, values: validValues } as HealpixCellsLayerProps) + resolveFrame({ nside: 64, values: validValues } as any) ).toThrow(/cellIds/); }); diff --git a/packages/deck.gl-healpix/src/utils/resolve-frame.ts b/packages/deck.gl-healpix/src/utils/resolve-frame.ts index 5693be2..d9414b2 100644 --- a/packages/deck.gl-healpix/src/utils/resolve-frame.ts +++ b/packages/deck.gl-healpix/src/utils/resolve-frame.ts @@ -1,9 +1,11 @@ import type { + HealpixColorMode, HealpixCellsLayerProps, HealpixFrameObject } from '../types/layer-props'; import type { CellIdArray } from '../types/cell-ids'; import { DEFAULT_COLORMAP, validateColorMap } from './color-map'; +import { HEALPIX_COLOR_MODE_SCALAR as DEFAULT_COLOR_MODE } from '../types/layer-props'; /** The fully resolved, validated frame ready for GPU upload. */ export type ResolvedFrame = { @@ -13,7 +15,12 @@ export type ResolvedFrame = { values: ArrayLike; min: number; max: number; - dimensions: 1 | 2 | 3 | 4; + colorMode: HealpixColorMode; + filterMin: number; + filterMax: number; + rescaleMin: number; + rescaleMax: number; + dimensions: number; colorMap: Uint8Array; }; @@ -65,9 +72,12 @@ export function resolveFrame(props: HealpixCellsLayerProps): ResolvedFrame { const colorMap = frame.colorMap ?? props.colorMap ?? DEFAULT_COLORMAP; validateColorMap(colorMap); // throws if wrong length - const dimensions = (frame.dimensions ?? - props.dimensions ?? - 1) as ResolvedFrame['dimensions']; + const dimensions = frame.dimensions ?? props.dimensions ?? 1; + if (!Number.isInteger(dimensions) || dimensions < 1) { + throw new Error( + `HealpixCellsLayer: dimensions (${dimensions}) must be a positive integer` + ); + } // values length check const expectedLen = cellIds.length * dimensions; @@ -84,6 +94,13 @@ export function resolveFrame(props: HealpixCellsLayerProps): ResolvedFrame { values, min: frame.min ?? props.min ?? 0, max: frame.max ?? props.max ?? 1, + colorMode: frame.colorMode ?? props.colorMode ?? DEFAULT_COLOR_MODE, + filterMin: frame.filterMin ?? props.filterMin ?? -Infinity, + filterMax: frame.filterMax ?? props.filterMax ?? Infinity, + rescaleMin: + frame.rescaleMin ?? props.rescaleMin ?? frame.min ?? props.min ?? 0, + rescaleMax: + frame.rescaleMax ?? props.rescaleMax ?? frame.max ?? props.max ?? 1, dimensions, colorMap }; From 6bbca67c069ff5b09dcb36de6a32ecdf6d041098 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 5 May 2026 16:15:19 +0100 Subject: [PATCH 04/24] Enable multi-texel packing for per-cell values --- .../src/utils/values-texture.test.ts | 30 ++++++++++++++++ .../src/utils/values-texture.ts | 36 +++++++++++-------- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/deck.gl-healpix/src/utils/values-texture.test.ts b/packages/deck.gl-healpix/src/utils/values-texture.test.ts index 2484bc6..29d7cb4 100644 --- a/packages/deck.gl-healpix/src/utils/values-texture.test.ts +++ b/packages/deck.gl-healpix/src/utils/values-texture.test.ts @@ -38,6 +38,36 @@ describe('packValuesData', () => { expect(data[3]).toBeCloseTo(0.4); }); + it('dim=5: packs one cell into two adjacent RGBA texels', () => { + const { data, width, height, texelsPerCell } = packValuesData( + [0.1, 0.2, 0.3, 0.4, 0.5], + 5, + 1, + MAX + ); + expect(width).toBe(2); + expect(height).toBe(1); + expect(texelsPerCell).toBe(2); + expect(data[0]).toBeCloseTo(0.1); + expect(data[1]).toBeCloseTo(0.2); + expect(data[2]).toBeCloseTo(0.3); + expect(data[3]).toBeCloseTo(0.4); + expect(data[4]).toBeCloseTo(0.5); + expect(data[5]).toBe(0); + }); + + it('dim=10: packs each cell into ceil(dimensions / 4) texels', () => { + const values = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 + ]; + const { data, texelsPerCell } = packValuesData(values, 10, 2, MAX); + expect(texelsPerCell).toBe(3); + expect(data[0]).toBe(1); + expect(data[8]).toBe(9); + expect(data[12]).toBe(11); + expect(data[20]).toBe(19); + }); + it('two cells, dim=1: each in a separate texel', () => { const { data, width } = packValuesData([0.2, 0.8], 1, 2, MAX); expect(width).toBe(2); diff --git a/packages/deck.gl-healpix/src/utils/values-texture.ts b/packages/deck.gl-healpix/src/utils/values-texture.ts index 418cf6e..c8de1ca 100644 --- a/packages/deck.gl-healpix/src/utils/values-texture.ts +++ b/packages/deck.gl-healpix/src/utils/values-texture.ts @@ -1,12 +1,11 @@ /** * Packs per-cell interleaved float values into an `RGBA32F` 2D texture layout. * - * The texture is folded: cell `i` maps to texel `(i % width, floor(i / width))`. - * Each texel has 4 floats (RGBA32F). Channels 0 through `dimensions-1` are - * filled; the rest remain 0. + * The texture is folded: each cell owns `ceil(dimensions / 4)` adjacent texels + * in linear order, then that linear texel stream is folded into 2D. * * @param values Interleaved float values. Length = cellCount × dimensions. - * @param dimensions Number of values per cell (1–4). + * @param dimensions Number of source values per cell. * @param cellCount Total number of cells. * @param maxTextureSize GPU max texture dimension (from device limits). */ @@ -15,25 +14,32 @@ export function packValuesData( dimensions: number, cellCount: number, maxTextureSize: number -): { data: Float32Array; width: number; height: number } { +): { + data: Float32Array; + width: number; + height: number; + texelsPerCell: number; +} { + const texelsPerCell = Math.max(1, Math.ceil(dimensions / 4)); if (cellCount === 0) { - return { data: new Float32Array(4), width: 1, height: 1 }; + return { data: new Float32Array(4), width: 1, height: 1, texelsPerCell }; } - const channelCount = Math.min(dimensions, 4); - const width = Math.min(cellCount, maxTextureSize); - const height = Math.ceil(cellCount / width); + const texelCount = cellCount * texelsPerCell; + const width = Math.min(texelCount, maxTextureSize); + const height = Math.ceil(texelCount / width); const data = new Float32Array(width * height * 4); for (let i = 0; i < cellCount; i++) { - const x = i % width; - const y = Math.floor(i / width); - const dstBase = (y * width + x) * 4; const srcBase = i * dimensions; - for (let d = 0; d < channelCount; d++) { - data[dstBase + d] = (values as number[])[srcBase + d] ?? 0; + for (let d = 0; d < dimensions; d++) { + const linearTexel = i * texelsPerCell + Math.floor(d / 4); + const x = linearTexel % width; + const y = Math.floor(linearTexel / width); + const dstBase = (y * width + x) * 4; + data[dstBase + (d % 4)] = (values as number[])[srcBase + d] ?? 0; } } - return { data, width, height }; + return { data, width, height, texelsPerCell }; } From 0d2703b22d80b0fed1ec0d901dc7a798de03fad3 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 5 May 2026 16:15:19 +0100 Subject: [PATCH 05/24] Introduce new GLSL shader modules for color pipeline stages --- .../shaders/healpix-color-shader-module.ts | 42 +++++++++ .../shaders/healpix-filter-shader-module.ts | 39 +++++++++ .../shaders/healpix-rescale-shader-module.ts | 44 ++++++++++ .../shaders/healpix-values-shader-module.ts | 85 +++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 packages/deck.gl-healpix/src/shaders/healpix-color-shader-module.ts create mode 100644 packages/deck.gl-healpix/src/shaders/healpix-filter-shader-module.ts create mode 100644 packages/deck.gl-healpix/src/shaders/healpix-rescale-shader-module.ts create mode 100644 packages/deck.gl-healpix/src/shaders/healpix-values-shader-module.ts diff --git a/packages/deck.gl-healpix/src/shaders/healpix-color-shader-module.ts b/packages/deck.gl-healpix/src/shaders/healpix-color-shader-module.ts new file mode 100644 index 0000000..63d04d5 --- /dev/null +++ b/packages/deck.gl-healpix/src/shaders/healpix-color-shader-module.ts @@ -0,0 +1,42 @@ +import type { Texture } from '@luma.gl/core'; +import type { ShaderModule } from '@luma.gl/shadertools'; + +export type HealpixColorProps = { + healpixColorMapTexture: Texture; +}; + +export const healpixColorShaderModule = { + name: 'healpixColor', + fs: `\ +uniform mediump sampler2D healpixColorMapTexture; +`, + inject: { + 'fs:DECKGL_FILTER_COLOR': { + order: -10, + injection: `\ +if (healpixColorMode == HEALPIX_COLOR_MODE_SCALAR) { + color = texelFetch( + healpixColorMapTexture, + ivec2(int(healpixSelectedValues.x * 255.0), 0), + 0 + ); +} else if (healpixColorMode == HEALPIX_COLOR_MODE_SCALAR_ALPHA) { + color = texelFetch( + healpixColorMapTexture, + ivec2(int(healpixSelectedValues.x * 255.0), 0), + 0 + ); + color.a *= healpixSelectedValues.y; +} else if (healpixColorMode == HEALPIX_COLOR_MODE_RGB) { + color = vec4(healpixSelectedValues.rgb, 1.0); +} else if (healpixColorMode == HEALPIX_COLOR_MODE_RGBA) { + color = healpixSelectedValues; +} else { + color = vec4(0.0); +} + +color.a *= layer.opacity; +` + } + } +} as const satisfies ShaderModule; diff --git a/packages/deck.gl-healpix/src/shaders/healpix-filter-shader-module.ts b/packages/deck.gl-healpix/src/shaders/healpix-filter-shader-module.ts new file mode 100644 index 0000000..ee8ed4f --- /dev/null +++ b/packages/deck.gl-healpix/src/shaders/healpix-filter-shader-module.ts @@ -0,0 +1,39 @@ +import type { ShaderModule } from '@luma.gl/shadertools'; + +export type HealpixFilterProps = { + uFilterMin: number; + uFilterMax: number; +}; + +export const healpixFilterShaderModule = { + name: 'healpixFilter', + fs: `\ +uniform healpixFilterUniforms { + float uFilterMin; + float uFilterMax; +} healpixFilter; +`, + inject: { + 'fs:DECKGL_FILTER_COLOR': { + order: -30, + injection: `\ +if ( + healpixColorMode == HEALPIX_COLOR_MODE_SCALAR || + healpixColorMode == HEALPIX_COLOR_MODE_SCALAR_ALPHA +) { + float healpixFilterValue = healpixSelectedValues.x; + if ( + healpixFilterValue < healpixFilter.uFilterMin || + healpixFilterValue > healpixFilter.uFilterMax + ) { + discard; + } +} +` + } + }, + uniformTypes: { + uFilterMin: 'f32', + uFilterMax: 'f32' + } +} as const satisfies ShaderModule; diff --git a/packages/deck.gl-healpix/src/shaders/healpix-rescale-shader-module.ts b/packages/deck.gl-healpix/src/shaders/healpix-rescale-shader-module.ts new file mode 100644 index 0000000..2760e50 --- /dev/null +++ b/packages/deck.gl-healpix/src/shaders/healpix-rescale-shader-module.ts @@ -0,0 +1,44 @@ +import type { ShaderModule } from '@luma.gl/shadertools'; + +export type HealpixRescaleProps = { + uRescaleMin: number; + uRescaleMax: number; +}; + +export const healpixRescaleShaderModule = { + name: 'healpixRescale', + fs: `\ +uniform healpixRescaleUniforms { + float uRescaleMin; + float uRescaleMax; +} healpixRescale; +`, + inject: { + 'fs:DECKGL_FILTER_COLOR': { + order: -20, + injection: `\ +if ( + healpixColorMode == HEALPIX_COLOR_MODE_SCALAR || + healpixColorMode == HEALPIX_COLOR_MODE_SCALAR_ALPHA +) { + float healpixRescaleDenom = + healpixRescale.uRescaleMax - healpixRescale.uRescaleMin; + healpixSelectedValues.x = healpixRescaleDenom == 0.0 + ? 0.0 + : clamp( + (healpixSelectedValues.x - healpixRescale.uRescaleMin) / + healpixRescaleDenom, + 0.0, + 1.0 + ); +} + +HEALPIX_RESCALE_VALUES(healpixSelectedValues, geometry); +` + } + }, + uniformTypes: { + uRescaleMin: 'f32', + uRescaleMax: 'f32' + } +} as const satisfies ShaderModule; diff --git a/packages/deck.gl-healpix/src/shaders/healpix-values-shader-module.ts b/packages/deck.gl-healpix/src/shaders/healpix-values-shader-module.ts new file mode 100644 index 0000000..0404a3d --- /dev/null +++ b/packages/deck.gl-healpix/src/shaders/healpix-values-shader-module.ts @@ -0,0 +1,85 @@ +import type { Texture } from '@luma.gl/core'; +import type { ShaderModule } from '@luma.gl/shadertools'; + +export type HealpixValuesProps = { + uDimensions: number; + uColorMode: number; + uValuesWidth: number; + uTexelsPerCell: number; + healpixValuesTexture: Texture; +}; + +export const healpixValuesShaderModule = { + name: 'healpixValues', + fs: `\ +in float vHealpixCellIndex; +uniform highp sampler2D healpixValuesTexture; + +uniform healpixValuesUniforms { + int uDimensions; + int uColorMode; + int uValuesWidth; + int uTexelsPerCell; +} healpixValues; + +const int HEALPIX_COLOR_MODE_SCALAR = 1; +const int HEALPIX_COLOR_MODE_SCALAR_ALPHA = 2; +const int HEALPIX_COLOR_MODE_RGB = 3; +const int HEALPIX_COLOR_MODE_RGBA = 4; + +int healpixCell; +int healpixDimensions; +int healpixColorMode; +vec4 healpixSelectedValues; + +// Forward declarations for the custom HEALPix hooks. The hook system emits +// the no-op bodies (and any user injections) after the deck.gl hook bodies, +// but our injections call them from inside DECKGL_FILTER_COLOR. GLSL requires +// a declaration before the call site, so declare them here. +void HEALPIX_SELECT_VALUES(inout vec4 selectedValues, FragmentGeometry geometry); +void HEALPIX_RESCALE_VALUES(inout vec4 selectedValues, FragmentGeometry geometry); + +float healpixValueAt(int channel) { + if (channel < 0 || channel >= healpixDimensions) { + return 0.0; + } + + int texel = channel / 4; + int component = channel - texel * 4; + int valueIndex = healpixCell * healpixValues.uTexelsPerCell + texel; + int x = valueIndex % healpixValues.uValuesWidth; + int y = valueIndex / healpixValues.uValuesWidth; + vec4 rgba = texelFetch(healpixValuesTexture, ivec2(x, y), 0); + + if (component == 0) return rgba.r; + if (component == 1) return rgba.g; + if (component == 2) return rgba.b; + if (component == 3) return rgba.a; + return 0.0; +} +`, + inject: { + 'fs:DECKGL_FILTER_COLOR': { + order: -40, + injection: `\ +healpixCell = int(vHealpixCellIndex + 0.5); +healpixDimensions = healpixValues.uDimensions; +healpixColorMode = healpixValues.uColorMode; +healpixSelectedValues = vec4(0.0); + +if (healpixDimensions >= 1) healpixSelectedValues.x = healpixValueAt(0); +if (healpixDimensions >= 2) healpixSelectedValues.y = healpixValueAt(1); +if (healpixDimensions >= 3) healpixSelectedValues.z = healpixValueAt(2); +if (healpixDimensions >= 4) healpixSelectedValues.w = healpixValueAt(3); + +HEALPIX_SELECT_VALUES(healpixSelectedValues, geometry); +` + } + }, + uniformTypes: { + uDimensions: 'i32', + uColorMode: 'i32', + uValuesWidth: 'i32', + uTexelsPerCell: 'i32' + } +} as const satisfies ShaderModule; From 91ab153d4834bccfb67825dfae4a8c97d8dda365 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 5 May 2026 16:15:19 +0100 Subject: [PATCH 06/24] Integrate new shader modules and update core layer logic --- .../src/layers/healpix-cells-layer.ts | 47 +++++-- .../layers/healpix-cells-primitive-layer.ts | 96 ++++++++++++-- .../src/shaders/healpix-cells.vs.glsl.ts | 3 + .../shaders/healpix-color-pipeline.test.ts | 120 ++++++++++++++++++ 4 files changed, 246 insertions(+), 20 deletions(-) create mode 100644 packages/deck.gl-healpix/src/shaders/healpix-color-pipeline.test.ts diff --git a/packages/deck.gl-healpix/src/layers/healpix-cells-layer.ts b/packages/deck.gl-healpix/src/layers/healpix-cells-layer.ts index 0d18e76..45b8fa2 100644 --- a/packages/deck.gl-healpix/src/layers/healpix-cells-layer.ts +++ b/packages/deck.gl-healpix/src/layers/healpix-cells-layer.ts @@ -6,9 +6,9 @@ import { UpdateParameters } from '@deck.gl/core'; import type { Texture } from '@luma.gl/core'; +import type { ShaderModule } from '@luma.gl/shadertools'; import { splitCellIds } from '../utils/split-cell-ids'; import { HealpixCellsPrimitiveLayer } from './healpix-cells-primitive-layer'; -import { HEALPIX_COLOR_EXTENSION } from '../extensions/healpix-color-extension'; import { resolveFrame, type ResolvedFrame } from '../utils/resolve-frame'; import { packValuesData } from '../utils/values-texture'; import type { CellIdArray } from '../types/cell-ids'; @@ -24,10 +24,16 @@ type _HealpixCellsLayerProps = { values: ArrayLike | null; min: number; max: number; - dimensions: 1 | 2 | 3 | 4; + colorMode?: number; + filterMin?: number; + filterMax?: number; + rescaleMin?: number; + rescaleMax?: number; + dimensions: number; colorMap: Uint8Array | null; frames: HealpixFrameObject[] | null; currentFrame: number; + shaderModules?: ShaderModule[]; }; type HealpixCellsLayerState = { @@ -37,6 +43,7 @@ type HealpixCellsLayerState = { valuesTexture: Texture | null; colorMapTexture: Texture | null; valuesTextureWidth: number; + valuesTexelsPerCell: number; prevResolved: ResolvedFrame | null; }; @@ -68,6 +75,7 @@ export class HealpixCellsLayer extends CompositeLayer { valuesTexture: null, colorMapTexture: null, valuesTextureWidth: 1, + valuesTexelsPerCell: 1, prevResolved: null }); this._rebuildAll(); @@ -122,12 +130,23 @@ export class HealpixCellsLayer extends CompositeLayer { valuesTexture, colorMapTexture, valuesTextureWidth, + valuesTexelsPerCell, prevResolved } = this.state; if (!valuesTexture || !colorMapTexture || !prevResolved) return []; - const { cellIds, nside, scheme, min, max, dimensions } = prevResolved; + const { + cellIds, + nside, + scheme, + filterMin, + filterMax, + rescaleMin, + rescaleMax, + colorMode, + dimensions + } = prevResolved; const count = cellIds.length; if (count === 0) return []; @@ -148,14 +167,16 @@ export class HealpixCellsLayer extends CompositeLayer { }, valuesTexture, colorMapTexture, - uMin: min, - uMax: max, + uFilterMin: filterMin, + uFilterMax: filterMax, + uRescaleMin: rescaleMin, + uRescaleMax: rescaleMax, uDimensions: dimensions, + uColorMode: colorMode, uValuesWidth: valuesTextureWidth, - extensions: [ - ...((this.props.extensions as LayerExtension[]) || []), - HEALPIX_COLOR_EXTENSION - ] + uTexelsPerCell: valuesTexelsPerCell, + shaderModules: this.props.shaderModules ?? [], + extensions: (this.props.extensions as LayerExtension[]) || [] }) ) ]; @@ -200,7 +221,7 @@ export class HealpixCellsLayer extends CompositeLayer { const { maxTextureDimension2D: maxTextureSize } = this.context.device.limits; - const { data, width, height } = packValuesData( + const { data, width, height, texelsPerCell } = packValuesData( values, dimensions, cellCount, @@ -233,7 +254,11 @@ export class HealpixCellsLayer extends CompositeLayer { }); texture.copyImageData({ data }); - this.setState({ valuesTexture: texture, valuesTextureWidth: width }); + this.setState({ + valuesTexture: texture, + valuesTextureWidth: width, + valuesTexelsPerCell: texelsPerCell + }); oldTexture?.destroy(); } diff --git a/packages/deck.gl-healpix/src/layers/healpix-cells-primitive-layer.ts b/packages/deck.gl-healpix/src/layers/healpix-cells-primitive-layer.ts index 3cb6c9a..5cb35a3 100644 --- a/packages/deck.gl-healpix/src/layers/healpix-cells-primitive-layer.ts +++ b/packages/deck.gl-healpix/src/layers/healpix-cells-primitive-layer.ts @@ -6,14 +6,18 @@ import { project32, UpdateParameters } from '@deck.gl/core'; -import { RenderPass } from '@luma.gl/core'; +import type { RenderPass, Texture } from '@luma.gl/core'; +import type { ShaderModule } from '@luma.gl/shadertools'; import { Geometry, Model } from '@luma.gl/engine'; import { HEALPIX_FRAGMENT_SHADER, HEALPIX_VERTEX_SHADER } from '../shaders'; import { computeHealpixCellsUniforms, healpixCellsShaderModule } from '../shaders/healpix-cells-shader-module'; -import type { HealpixColorExtensionProps } from '../extensions/healpix-color-extension'; +import { healpixColorShaderModule } from '../shaders/healpix-color-shader-module'; +import { healpixFilterShaderModule } from '../shaders/healpix-filter-shader-module'; +import { healpixRescaleShaderModule } from '../shaders/healpix-rescale-shader-module'; +import { healpixValuesShaderModule } from '../shaders/healpix-values-shader-module'; /** Props for the GPU-instanced HEALPix cell primitive layer. */ export type HealpixCellsPrimitiveLayerProps = { @@ -22,8 +26,22 @@ export type HealpixCellsPrimitiveLayerProps = { instanceCount: number; }; +type HealpixColorPipelineProps = { + valuesTexture: Texture; + colorMapTexture: Texture; + uFilterMin: number; + uFilterMax: number; + uRescaleMin: number; + uRescaleMax: number; + uDimensions: number; + uColorMode: number; + uValuesWidth: number; + uTexelsPerCell: number; + shaderModules?: ShaderModule[]; +}; + type HealpixCellsPrimitiveLayerMergedProps = HealpixCellsPrimitiveLayerProps & - HealpixColorExtensionProps; + HealpixColorPipelineProps; const defaultProps: DefaultProps = { nside: { type: 'number', value: 1 }, @@ -36,6 +54,23 @@ const defaultProps: DefaultProps = { const QUAD_INDICES = new Uint16Array([0, 1, 2, 0, 2, 3]); const QUAD_POSITIONS = new Float32Array(12); +/** + * Registers the custom HEALPix shader hooks (`HEALPIX_SELECT_VALUES`, + * `HEALPIX_RESCALE_VALUES`) on the layer context's `ShaderAssembler` so that + * user-supplied shader modules can inject GLSL into the pipeline. Hooks are + * registered unconditionally on every `initializeState`; luma.gl deduplicates + * by hook name, so re-registration is safe across layer remounts (e.g. React + * StrictMode) and across multiple HEALPix layer instances. + */ +function registerHealpixPipelineHooks(context: LayerContext): void { + context.shaderAssembler.addShaderHook( + 'fs:HEALPIX_SELECT_VALUES(inout vec4 selectedValues, FragmentGeometry geometry)' + ); + context.shaderAssembler.addShaderHook( + 'fs:HEALPIX_RESCALE_VALUES(inout vec4 selectedValues, FragmentGeometry geometry)' + ); +} + export class HealpixCellsPrimitiveLayer extends Layer { static layerName = 'HealpixCellsPrimitiveLayer'; static defaultProps = defaultProps; @@ -50,20 +85,45 @@ export class HealpixCellsPrimitiveLayer extends Layer): void { super.updateState(params); - if (params.changeFlags.extensionsChanged || !this.state.model) { + const shaderModulesChanged = + params.props.shaderModules !== params.oldProps?.shaderModules; + if ( + params.changeFlags.extensionsChanged || + shaderModulesChanged || + !this.state.model + ) { this.state.model?.destroy(); this.state.model = this._getModel(); this.getAttributeManager()!.invalidateAll(); @@ -84,7 +144,25 @@ export class HealpixCellsPrimitiveLayer extends Layer { + it('modules inject stages rather than exporting pipeline functions', () => { + expect(healpixValuesShaderModule.inject).toHaveProperty( + 'fs:DECKGL_FILTER_COLOR' + ); + expect(healpixFilterShaderModule.inject).toHaveProperty( + 'fs:DECKGL_FILTER_COLOR' + ); + expect(healpixRescaleShaderModule.inject).toHaveProperty( + 'fs:DECKGL_FILTER_COLOR' + ); + expect(healpixColorShaderModule.inject).toHaveProperty( + 'fs:DECKGL_FILTER_COLOR' + ); + + expect(healpixValuesShaderModule.fs).toContain('healpixValueAt'); + expect(healpixValuesShaderModule.fs).toContain('healpixSelectedValues'); + expect(healpixValuesShaderModule.fs).toContain('uTexelsPerCell'); + expect(healpixValuesShaderModule.fs).toContain('healpixColorMode'); + expect( + injectionSource( + healpixValuesShaderModule.inject?.['fs:DECKGL_FILTER_COLOR'] + ) + ).toContain('HEALPIX_SELECT_VALUES'); + expect( + injectionSource( + healpixRescaleShaderModule.inject?.['fs:DECKGL_FILTER_COLOR'] + ) + ).toContain('HEALPIX_RESCALE_VALUES'); + expect(healpixColorShaderModule.fs).not.toContain('healpixApplyColor'); + }); + + it('uses colorMode for interpretation and dimensions for raw value access', () => { + expect(healpixValuesShaderModule.fs).toContain('int texel = channel / 4;'); + expect(healpixValuesShaderModule.fs).toContain( + 'healpixCell * healpixValues.uTexelsPerCell + texel' + ); + expect( + healpixFilterShaderModule.inject?.['fs:DECKGL_FILTER_COLOR'] + ).toEqual( + expect.objectContaining({ + injection: expect.stringContaining('HEALPIX_COLOR_MODE_SCALAR') + }) + ); + expect( + injectionSource( + healpixColorShaderModule.inject?.['fs:DECKGL_FILTER_COLOR'] + ) + ).toContain('healpixColorMode == HEALPIX_COLOR_MODE_RGBA'); + }); + + it('orders built-in deck hook injections before default user injections', () => { + const builtInOrders = [ + healpixValuesShaderModule.inject?.['fs:DECKGL_FILTER_COLOR'], + healpixFilterShaderModule.inject?.['fs:DECKGL_FILTER_COLOR'], + healpixRescaleShaderModule.inject?.['fs:DECKGL_FILTER_COLOR'], + healpixColorShaderModule.inject?.['fs:DECKGL_FILTER_COLOR'] + ].map((injection) => + typeof injection === 'string' ? 0 : (injection?.order ?? 0) + ); + + expect(builtInOrders).toEqual([-40, -30, -20, -10]); + expect(Math.max(...builtInOrders)).toBeLessThan(0); + }); + + it('passes cell index from vertex to fragment shader', () => { + expect(HEALPIX_CELLS_VS_MAIN).toContain('in float healpixCellIndex;'); + expect(HEALPIX_CELLS_VS_MAIN).toContain('out float vHealpixCellIndex;'); + expect(HEALPIX_CELLS_VS_MAIN).toContain( + 'vHealpixCellIndex = healpixCellIndex;' + ); + }); + + it('keeps the fragment shader as the hook host', () => { + expect(HEALPIX_CELLS_FS).toContain( + 'DECKGL_FILTER_COLOR(fragColor, geometry);' + ); + expect(HEALPIX_CELLS_FS).not.toContain('healpixApplyColor'); + expect(HEALPIX_CELLS_FS).not.toContain('healpixDiscardIfFiltered'); + }); + + it('appends user shader modules after built-in pipeline modules', () => { + const primitiveLayerSource = readFileSync( + join(__dirname, '../layers/healpix-cells-primitive-layer.ts'), + 'utf8' + ); + + expect(primitiveLayerSource).toContain('healpixValuesShaderModule'); + expect(primitiveLayerSource).toContain('healpixFilterShaderModule'); + expect(primitiveLayerSource).toContain('healpixRescaleShaderModule'); + expect(primitiveLayerSource).toContain('healpixColorShaderModule'); + expect(primitiveLayerSource).toContain( + '...(this.props.shaderModules ?? [])' + ); + expect(primitiveLayerSource).toContain('shaderModulesChanged'); + expect(primitiveLayerSource).toContain( + 'fs:HEALPIX_SELECT_VALUES(inout vec4 selectedValues, FragmentGeometry geometry)' + ); + expect(primitiveLayerSource).toContain( + 'fs:HEALPIX_RESCALE_VALUES(inout vec4 selectedValues, FragmentGeometry geometry)' + ); + }); +}); From 796a5e5389abd6fd2515fcca233c209afda49786 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 5 May 2026 16:15:19 +0100 Subject: [PATCH 07/24] Remove legacy HealpixColorExtension --- .../src/extensions/healpix-color-extension.ts | 132 ------------------ .../extensions/healpix-color-shader-module.ts | 42 ------ packages/deck.gl-healpix/src/index.ts | 7 + 3 files changed, 7 insertions(+), 174 deletions(-) delete mode 100644 packages/deck.gl-healpix/src/extensions/healpix-color-extension.ts delete mode 100644 packages/deck.gl-healpix/src/extensions/healpix-color-shader-module.ts diff --git a/packages/deck.gl-healpix/src/extensions/healpix-color-extension.ts b/packages/deck.gl-healpix/src/extensions/healpix-color-extension.ts deleted file mode 100644 index 345d1e1..0000000 --- a/packages/deck.gl-healpix/src/extensions/healpix-color-extension.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Layer, LayerExtension, LayerProps } from '@deck.gl/core'; -import type { Texture } from '@luma.gl/core'; -import { healpixColorShaderModule } from './healpix-color-shader-module'; - -/** Extra props this extension reads from the host primitive layer. */ -export type HealpixColorExtensionProps = LayerProps & { - valuesTexture: Texture; - colorMapTexture: Texture; - uMin: number; - uMax: number; - uDimensions: number; - uValuesWidth: number; -}; - -/** - * GLSL declaration injected into the vertex shader. - * - * Declares the two texture samplers plus the instanced `healpixCellIndex` - * attribute used to look the per-cell float values up in the values texture. - */ -const VERTEX_DECLARATION_INJECT = ` -uniform highp sampler2D healpixValuesTexture; -uniform mediump sampler2D healpixColorMapTexture; -in float healpixCellIndex; -`; - -/** - * GLSL injected into deck.gl's DECKGL_FILTER_COLOR hook. - * - * Samples per-cell float values from the values texture, then computes - * the output color based on the dimensions mode: - * - * dimensions=1 scalar → normalized through [min,max] → colorMap LUT - * dimensions=2 scalar (→ colorMap) + opacity multiplier (second value) - * dimensions=3 direct RGB in 0–1; alpha=1 - * dimensions=4 direct RGBA in 0–1 - * else transparent - */ -const VERTEX_COLOR_FILTER_INJECT = ` -int healpixCell = int(healpixCellIndex + 0.5); -int healpixX = healpixCell % healpixColor.uValuesWidth; -int healpixY = healpixCell / healpixColor.uValuesWidth; -vec4 healpixVals = texelFetch(healpixValuesTexture, ivec2(healpixX, healpixY), 0); - -float healpixDenom = healpixColor.uMax - healpixColor.uMin; -float healpixT = healpixDenom == 0.0 - ? 0.0 - : clamp((healpixVals.r - healpixColor.uMin) / healpixDenom, 0.0, 1.0); - -vec4 healpixOut; -if (healpixColor.uDimensions == 1) { - healpixOut = texelFetch(healpixColorMapTexture, ivec2(int(healpixT * 255.0), 0), 0); -} else if (healpixColor.uDimensions == 2) { - healpixOut = texelFetch(healpixColorMapTexture, ivec2(int(healpixT * 255.0), 0), 0); - healpixOut.a *= healpixVals.g; -} else if (healpixColor.uDimensions == 3) { - healpixOut = vec4(healpixVals.rgb, 1.0); -} else if (healpixColor.uDimensions == 4) { - healpixOut = healpixVals; -} else { - healpixOut = vec4(0.0); -} -color = vec4(healpixOut.rgb, healpixOut.a * layer.opacity); -`; - -/** - * Layer extension that computes HEALPix cell colors on the GPU. - * - * Reads per-cell float values from an RGBA32F texture and converts them - * to RGBA using a 256×1 colorMap LUT texture, driven by the `dimensions` mode. - * Replaces `HealpixColorFramesExtension`. - */ -class HealpixColorExtension extends LayerExtension { - static extensionName = 'HealpixColorExtension'; - - /** - * Register `healpixCellIndex` as an instanced attribute so all four quad - * vertices of a cell share the same index (required for instanced drawing). - */ - initializeState(this: Layer): void { - this.getAttributeManager()?.add({ - healpixCellIndex: { - size: 1, - type: 'float32', - stepMode: 'instance', - accessor: 'healpixCellIndex', - defaultValue: 0, - noAlloc: true - } - }); - } - - getShaders(): unknown { - return { - modules: [healpixColorShaderModule], - inject: { - 'vs:#decl': VERTEX_DECLARATION_INJECT, - 'vs:DECKGL_FILTER_COLOR': VERTEX_COLOR_FILTER_INJECT - } - }; - } - - draw( - this: Layer, - _opts: { uniforms: unknown } - ): void { - const { - valuesTexture, - colorMapTexture, - uMin, - uMax, - uDimensions, - uValuesWidth - } = this.props as HealpixColorExtensionProps; - - for (const model of this.getModels()) { - model.shaderInputs.setProps({ - healpixColor: { - uMin, - uMax, - uDimensions, - uValuesWidth, - healpixValuesTexture: valuesTexture, - healpixColorMapTexture: colorMapTexture - } - }); - } - } -} - -/** Shared singleton used by all HEALPix sublayers. */ -export const HEALPIX_COLOR_EXTENSION = new HealpixColorExtension(); diff --git a/packages/deck.gl-healpix/src/extensions/healpix-color-shader-module.ts b/packages/deck.gl-healpix/src/extensions/healpix-color-shader-module.ts deleted file mode 100644 index 8df6a65..0000000 --- a/packages/deck.gl-healpix/src/extensions/healpix-color-shader-module.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Texture } from '@luma.gl/core'; -import type { ShaderModule } from '@luma.gl/shadertools'; - -/** - * Props consumed by the HEALPix color shader module. - * - * Scalar uniforms go into the `healpixColorUniforms` block. - * Texture bindings (`healpixValuesTexture`, `healpixColorMapTexture`) are - * declared separately in the `vs:#decl` injection in the extension. - */ -export type HealpixColorProps = { - uMin: number; - uMax: number; - uDimensions: number; - uValuesWidth: number; - healpixValuesTexture: Texture; - healpixColorMapTexture: Texture; -}; - -/** - * Shader module for GPU color computation. - * - * Declares the `healpixColor` uniform block (scalar uniforms). - * Textures are bound alongside these props via `model.shaderInputs.setProps`. - */ -export const healpixColorShaderModule = { - name: 'healpixColor', - vs: `\ -uniform healpixColorUniforms { - float uMin; - float uMax; - int uDimensions; - int uValuesWidth; -} healpixColor; -`, - uniformTypes: { - uMin: 'f32', - uMax: 'f32', - uDimensions: 'i32', - uValuesWidth: 'i32' - } -} as const satisfies ShaderModule; diff --git a/packages/deck.gl-healpix/src/index.ts b/packages/deck.gl-healpix/src/index.ts index 7942dbf..bdd3323 100644 --- a/packages/deck.gl-healpix/src/index.ts +++ b/packages/deck.gl-healpix/src/index.ts @@ -8,7 +8,14 @@ export type { } from './utils/color-map'; export type { CellIdArray } from './types/cell-ids'; export type { + HealpixColorMode, HealpixCellsLayerProps, HealpixFrameObject, HealpixScheme } from './types/layer-props'; +export { + HEALPIX_COLOR_MODE_RGBA, + HEALPIX_COLOR_MODE_RGB, + HEALPIX_COLOR_MODE_SCALAR, + HEALPIX_COLOR_MODE_SCALAR_ALPHA +} from './types/layer-props'; From 2280a3daa233e54351e82b3272a6fdf23fc00ce3 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 5 May 2026 16:15:19 +0100 Subject: [PATCH 08/24] Update package dependencies and Lerna configuration --- examples/sandbox/package.json | 3 +- lerna.json | 2 +- package-lock.json | 67 ++------------------------- packages/deck.gl-healpix/package.json | 3 +- 4 files changed, 8 insertions(+), 67 deletions(-) diff --git a/examples/sandbox/package.json b/examples/sandbox/package.json index 5bac2b9..31c3854 100644 --- a/examples/sandbox/package.json +++ b/examples/sandbox/package.json @@ -50,8 +50,7 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "react-map-gl": "^8.1.1", - "react-router": "^7.14.2", - "zarrita": "^0.6.2" + "react-router": "^7.14.2" }, "alias": { "$components": "~/app/components", diff --git a/lerna.json b/lerna.json index 8c44f15..0624888 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", "version": "0.1.0", - "packages": ["packages/*"], + "packages": ["packages/*", "examples/*"], "npmClient": "npm" } diff --git a/package-lock.json b/package-lock.json index 1779490..06ef944 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "healpix-layers-deck.gl", + "name": "feature-color-pipeline", "lockfileVersion": 3, "requires": true, "packages": { @@ -70,8 +70,7 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "react-map-gl": "^8.1.1", - "react-router": "^7.14.2", - "zarrita": "^0.6.2" + "react-router": "^7.14.2" }, "devDependencies": { "@types/babel__core": "^7", @@ -85,38 +84,6 @@ "node": "22.x" } }, - "examples/sandbox/node_modules/@zarrita/storage": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.4.tgz", - "integrity": "sha512-qURfJAQcQGRfDQ4J9HaCjGaj3jlJKc66bnRk6G/IeLUsM7WKyG7Bzsuf1EZurSXyc0I4LVcu6HaeQQ4d3kZ16g==", - "license": "MIT", - "dependencies": { - "reference-spec-reader": "^0.2.0", - "unzipit": "1.4.3" - } - }, - "examples/sandbox/node_modules/unzipit": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz", - "integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==", - "license": "MIT", - "dependencies": { - "uzip-module": "^1.0.2" - }, - "engines": { - "node": ">=12" - } - }, - "examples/sandbox/node_modules/zarrita": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.6.2.tgz", - "integrity": "sha512-8IV+2bWt5yiHNVK9GVEVK1tscpqDcJj8iz5cIKFOiWiWYUsK4V5njgMtnpkvKu6L7K+Og6zUShd8f+dwb6LvTA==", - "license": "MIT", - "dependencies": { - "@zarrita/storage": "^0.1.4", - "numcodecs": "^0.3.2" - } - }, "node_modules/@ark-ui/react": { "version": "5.36.2", "resolved": "https://registry.npmjs.org/@ark-ui/react/-/react-5.36.2.tgz", @@ -9419,12 +9386,6 @@ } } }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "license": "MIT" - }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -13882,15 +13843,6 @@ "node": ">=8" } }, - "node_modules/numcodecs": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", - "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", - "license": "MIT", - "dependencies": { - "fflate": "^0.8.0" - } - }, "node_modules/nwsapi": { "version": "2.2.23", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", @@ -15371,12 +15323,6 @@ "node": ">=8" } }, - "node_modules/reference-spec-reader": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", - "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", - "license": "MIT" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -17191,12 +17137,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/uzip-module": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz", - "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==", - "license": "MIT" - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -17935,7 +17875,8 @@ "@deck.gl/core": "~9.3.1", "@deck.gl/layers": "~9.3.1", "@luma.gl/core": "9.3.3", - "@luma.gl/engine": "9.3.3" + "@luma.gl/engine": "9.3.3", + "@luma.gl/shadertools": "9.3.3" } } } diff --git a/packages/deck.gl-healpix/package.json b/packages/deck.gl-healpix/package.json index 6089213..b1c41af 100644 --- a/packages/deck.gl-healpix/package.json +++ b/packages/deck.gl-healpix/package.json @@ -50,7 +50,8 @@ "@deck.gl/core": "~9.3.1", "@deck.gl/layers": "~9.3.1", "@luma.gl/core": "9.3.3", - "@luma.gl/engine": "9.3.3" + "@luma.gl/engine": "9.3.3", + "@luma.gl/shadertools": "9.3.3" }, "devDependencies": { "healpix-ts": "^1.0.0" From 504afa78c7b66c405c0b0a240836ad37445da0f2 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 5 May 2026 16:15:19 +0100 Subject: [PATCH 09/24] Update READMEs and sandbox example for new color pipeline API --- README.md | 2 +- examples/_shared/components/color-scheme.tsx | 4 +- examples/sandbox/app/pages/color/index.tsx | 107 ++++++++-- packages/deck.gl-healpix/README.md | 208 ++++++++++++++----- 4 files changed, 244 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 97b39cf..6d08dce 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ https://github.com/user-attachments/assets/4166d5d5-65e3-4309-a63a-0a2d0cdf275d | Package | Description | | -------- | ----------- | -| [`@developmentseed/deck.gl-healpix`](./packages/deck.gl-healpix/) | deck.gl layer for rendering [HEALPix](https://healpix.sourceforge.io/) cells on a map, with GPU-side colormaps and multi-frame animation. | +| [`@developmentseed/deck.gl-healpix`](./packages/deck.gl-healpix/) | deck.gl layer for rendering [HEALPix](https://healpix.sourceforge.io/) cells on a map, with a GPU color pipeline (filter / rescale / colorMap), multi-frame animation, and pluggable fragment-shader hooks for custom GLSL. | Each package has its own **README** with installation, API usage, and examples—start there for day-to-day integration work. diff --git a/examples/_shared/components/color-scheme.tsx b/examples/_shared/components/color-scheme.tsx index 3fd6f3c..39c0866 100644 --- a/examples/_shared/components/color-scheme.tsx +++ b/examples/_shared/components/color-scheme.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import { useMemo } from 'react'; import { Box, Portal, @@ -18,7 +18,7 @@ export { schemeFns, type ColorSchemeName }; function makeBg(scheme: keyof typeof schemeFns) { const scale = scaleSequential((t) => schemeFns[scheme](t)).domain([0, 1]); - const colors = []; + const colors: string[] = []; for (let i = 0; i <= 1; i += 0.01) { colors.push(scale(i)); } diff --git a/examples/sandbox/app/pages/color/index.tsx b/examples/sandbox/app/pages/color/index.tsx index 17fcf53..9a7d2fb 100644 --- a/examples/sandbox/app/pages/color/index.tsx +++ b/examples/sandbox/app/pages/color/index.tsx @@ -3,6 +3,8 @@ import Map from 'react-map-gl/maplibre'; import 'maplibre-gl/dist/maplibre-gl.css'; import { Box, Field, Flex, NativeSelect, Slider, Text } from '@chakra-ui/react'; import { + HEALPIX_COLOR_MODE_RGB, + HEALPIX_COLOR_MODE_SCALAR, HealpixCellsLayer, makeColorMap } from '@developmentseed/deck.gl-healpix'; @@ -76,8 +78,10 @@ export default function PageColor() { const [visualization, setVisualization] = useState('true_color'); - const [indexMin, setIndexMin] = useState(0); - const [indexMax, setIndexMax] = useState(1); + const [rescaleMin, setRescaleMin] = useState(0); + const [rescaleMax, setRescaleMax] = useState(1); + const [filterMin, setFilterMin] = useState(-Infinity); + const [filterMax, setFilterMax] = useState(Infinity); const [colorScheme, setColorScheme] = useState('interpolateViridis'); @@ -107,8 +111,10 @@ export default function PageColor() { useEffect(() => { const [lo, hi] = defaultDisplayRange(visualization); - setIndexMin(lo); - setIndexMax(hi); + setRescaleMin(lo); + setRescaleMax(hi); + setFilterMin(-Infinity); + setFilterMax(Infinity); }, [visualization]); const ndvi = isNdviMode(visualization); @@ -117,6 +123,12 @@ export default function PageColor() { const displayRangeMin = ndvi ? -1 : 0; const displayRangeMax = 1; + const filterSliderMin = Number.isFinite(filterMin) + ? filterMin + : displayRangeMin; + const filterSliderMax = Number.isFinite(filterMax) + ? filterMax + : displayRangeMax; const colorMap = useMemo( () => @@ -140,8 +152,11 @@ export default function PageColor() { cellIds, values, dimensions: 1, - min: indexMin, - max: indexMax, + colorMode: HEALPIX_COLOR_MODE_SCALAR, + rescaleMin, + rescaleMax, + filterMin, + filterMax, colorMap }) ]; @@ -157,8 +172,11 @@ export default function PageColor() { cellIds, values, dimensions: 1, - min: indexMin, - max: indexMax, + colorMode: HEALPIX_COLOR_MODE_SCALAR, + rescaleMin, + rescaleMax, + filterMin, + filterMax, colorMap }) ]; @@ -175,12 +193,21 @@ export default function PageColor() { cellIds, values, dimensions: 3, + colorMode: HEALPIX_COLOR_MODE_RGB, min: 0, max: 1, colorMap }) ]; - }, [zarrData, visualization, colorMap, indexMin, indexMax]); + }, [ + zarrData, + visualization, + colorMap, + rescaleMin, + rescaleMax, + filterMin, + filterMax + ]); return ( @@ -204,10 +231,10 @@ export default function PageColor() { Sentinel 2 scene with 10 bands in healpix
- Cell coloring is computed on the GPU according the the values and - number of bands. Visualizations with 3 bands are rendered directly - as RGB. Visualizations with 1 band are mapped to a color scheme and - possibly rescaled. + Cell coloring is computed on the GPU from the selected source + values and color mode. RGB visualizations render selected values + directly, while scalar visualizations map through a color scheme + after optional filtering and rescaling. @@ -262,23 +289,18 @@ export default function PageColor() { {ndvi ? 'NDVI rescale' : 'Rescale'} - - {ndvi - ? 'Typical index roughly −1 … 1. Min/max set the display stretch.' - : 'Adjust min/max for single-band display stretch (0…1).'} - { const [a, b] = value; const lo = Math.min(a, b); const hi = Math.max(a, b); - setIndexMin(lo); - setIndexMax(hi); + setRescaleMin(lo); + setRescaleMax(hi); }} > @@ -289,8 +311,47 @@ export default function PageColor() { - Min: {indexMin.toFixed(3)} - Max: {indexMax.toFixed(3)} + Min: {rescaleMin.toFixed(3)} + Max: {rescaleMax.toFixed(3)} + + + )} + + {showScalarControls && ( + + + Visibility filter + + { + const [a, b] = value; + const lo = Math.min(a, b); + const hi = Math.max(a, b); + setFilterMin(lo); + setFilterMax(hi); + }} + > + + + + + + + + + + Min:{' '} + {Number.isFinite(filterMin) ? filterMin.toFixed(3) : 'none'} + + + Max:{' '} + {Number.isFinite(filterMax) ? filterMax.toFixed(3) : 'none'} + )} diff --git a/packages/deck.gl-healpix/README.md b/packages/deck.gl-healpix/README.md index 2b8c8e7..7a0a6ee 100644 --- a/packages/deck.gl-healpix/README.md +++ b/packages/deck.gl-healpix/README.md @@ -6,8 +6,9 @@ # deck.gl-healpix -A [deck.gl](https://deck.gl/) layer for rendering [HEALPix](https://healpix.sourceforge.io/) (Hierarchical Equal Area isoLatitude Pixelization) cells on a map. -It is especially suited for animating a large number of cells: per-cell values are uploaded once to the GPU and a small colorMap lookup is applied every frame on the vertex shader. +A [deck.gl](https://deck.gl/) layer for rendering [HEALPix](https://healpix.sourceforge.io/) (Hierarchical Equal Area isoLatitude Pixelization) cells on a map. + +It is suited for animating a large number of cells: per-cell values are uploaded once to the GPU and a configurable color pipeline (filter → rescale → color) runs every frame in the fragment shader. The pipeline is composed of luma.gl shader modules and exposes hook points so you can inject custom GLSL (band math, gamma rescale, classification, etc.) without forking the layer. https://github.com/user-attachments/assets/4166d5d5-65e3-4309-a63a-0a2d0cdf275d @@ -27,7 +28,7 @@ npm install @deck.gl/core @deck.gl/layers @luma.gl/core @luma.gl/engine ### Single frame -Pass per-cell numeric `values` plus a `[min, max]` range. The layer normalizes each value and maps it through a 256-entry `colorMap` LUT on the GPU. If `colorMap` is omitted a linear black-to-white ramp is used. +Pass per-cell numeric `values` plus a `[rescaleMin, rescaleMax]` range. The layer normalizes each value and maps it through a 256-entry `colorMap` LUT on the GPU. If `colorMap` is omitted a linear black-to-white ramp is used. ```ts import { HealpixCellsLayer } from '@developmentseed/deck.gl-healpix'; @@ -40,11 +41,13 @@ const layer = new HealpixCellsLayer({ nside: 64, cellIds, values, - min: 0, - max: 1 + rescaleMin: 0, + rescaleMax: 1 }); ``` +`min` and `max` are still accepted as backwards-compatible aliases for `rescaleMin` / `rescaleMax`. + ### Multi-frame animation Provide a `frames` array whose entries override the root-level defaults. Advance `currentFrame` to switch between them — no GPU re-upload happens unless the underlying typed array changes. @@ -58,8 +61,8 @@ const layer = new HealpixCellsLayer({ id: 'healpix', nside: 64, cellIds, - min: 0, - max: 1, + rescaleMin: 0, + rescaleMax: 1, frames: [ { values: new Float32Array([0.0, 0.25, 0.5, 0.75]) }, { values: new Float32Array([1.0, 0.75, 0.5, 0.25]) } @@ -68,26 +71,53 @@ const layer = new HealpixCellsLayer({ }); ``` -Each frame may override any root-level field (`nside`, `scheme`, `cellIds`, `values`, `min`, `max`, `dimensions`, `colorMap`). Fields omitted on a frame fall back to the root value. +Each frame may override any root-level field (`nside`, `scheme`, `cellIds`, `values`, `dimensions`, `colorMode`, `filterMin`, `filterMax`, `rescaleMin`, `rescaleMax`, `colorMap`, plus the legacy `min` / `max`). Fields omitted on a frame fall back to the root value. `shaderModules` is the only render-pipeline prop that is root-only. + +### Filter and rescale + +In scalar modes the layer runs a two-stage pipeline before the colorMap lookup: + +- **Filter** — cells whose first value is outside `[filterMin, filterMax]` are discarded entirely (they do not contribute to picking either). Default: unbounded. +- **Rescale** — surviving values are linearly normalized through `[rescaleMin, rescaleMax]` and clamped to `[0, 1]` before the LUT lookup. + +```ts +new HealpixCellsLayer({ + id: 'ndvi', + nside, + cellIds, + values, + filterMin: 0.2, // hide bare soil / water + rescaleMin: -0.1, + rescaleMax: 0.8 +}); +``` ### Direct RGB / RGBA values -Skip the colorMap and push color directly to the GPU by setting `dimensions` to `3` or `4`. Values are interpreted as normalized channels (`0.0`–`1.0`) interleaved per cell. +Set `dimensions` to the number of source channels and pick a non-scalar `colorMode` to push color directly to the GPU, bypassing the colorMap. Values are interpreted as normalized channels (`0.0`–`1.0`) interleaved per cell. ```ts -const layer = new HealpixCellsLayer({ +import { + HealpixCellsLayer, + HEALPIX_COLOR_MODE_RGB +} from '@developmentseed/deck.gl-healpix'; + +new HealpixCellsLayer({ id: 'healpix', nside: 64, cellIds: new Uint32Array([0, 1]), dimensions: 3, + colorMode: HEALPIX_COLOR_MODE_RGB, // cell 0 → red, cell 1 → green values: new Float32Array([1, 0, 0, 0, 1, 0]) }); ``` +`dimensions` and `colorMode` are independent: `dimensions` controls how many source values are stored per cell (it can exceed `4`); `colorMode` controls how the selected `vec4` is interpreted for rendering. + ### Custom colorMap -A `colorMap` is a `Uint8Array` of exactly **256 × 4 = 1024 bytes** in RGBA order. Index `0` maps to `min`, index `255` to `max`. +A `colorMap` is a `Uint8Array` of exactly **256 × 4 = 1024 bytes** in RGBA order. Index `0` maps to `rescaleMin`, index `255` to `rescaleMax`. The `makeColorMap` helper builds one from a callback that is invoked 256 times with the normalized position `t = i / 255` and the raw byte index `i`. Return a hex string, a `[r, g, b]`/`[r, g, b, a]` tuple in `0`–`255`, or `{ normalized: true, rgba: [...] }` in `0`–`1`. @@ -116,33 +146,111 @@ You can also build the buffer yourself — the layer will accept any `Uint8Array A `CompositeLayer` that renders HEALPix cells as filled polygons whose colors are computed on the GPU from per-cell float `values`. -| Prop | Type | Default | Description | -| -------------- | ----------------------- | ------------- | -------------------------------------------------------------------------------------------------- | -| `nside` | `number` | `0` | HEALPix resolution parameter (power of 2). Required on the layer or on every frame. | -| `cellIds` | `CellIdArray` | `Uint32Array(0)` | HEALPix cell indices to render. Required on the layer or on every frame. | -| `scheme` | `'nest' \| 'ring'` | `'nest'` | Pixel numbering scheme. | -| `values` | `ArrayLike` | — | Interleaved per-cell float values. Length = `cellIds.length × dimensions`. Required when `frames` is absent. | -| `min` | `number` | `0` | Value mapped to colorMap index 0. | -| `max` | `number` | `1` | Value mapped to colorMap index 255. | -| `dimensions` | `1 \| 2 \| 3 \| 4` | `1` | Number of values per cell. See table below. | -| `colorMap` | `Uint8Array` (1024 B) | black → white | 256-entry RGBA LUT used when `dimensions` is `1` or `2`. | -| `frames` | `HealpixFrameObject[]` | — | Optional animation frames; each may override any root field. | -| `currentFrame` | `number` | `0` | Active index into `frames`. Clamped to `[0, frames.length - 1]`. | - -### `dimensions` modes - -| `dimensions` | Interpretation | -| ------------ | ------------------------------------------------------------------------------ | -| `1` | Scalar → normalized through `[min, max]` → colorMap LUT → RGBA | -| `2` | Scalar (→ colorMap) + opacity multiplier (`0`–`1`) in the second value | -| `3` | Direct RGB in `0`–`1`; `colorMap` / `min` / `max` ignored; alpha = `1` | -| `4` | Direct RGBA in `0`–`1`; `colorMap` / `min` / `max` ignored | +| Prop | Type | Default | Description | +| --------------- | --------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `nside` | `number` | — | HEALPix resolution parameter (power of 2). Required on the layer or on every frame. | +| `cellIds` | `CellIdArray` | — | HEALPix cell indices to render. Required on the layer or on every frame. | +| `scheme` | `'nest' \| 'ring'` | `'nest'` | Pixel numbering scheme. | +| `values` | `ArrayLike` | — | Interleaved per-cell float values. Length = `cellIds.length × dimensions`. Required when `frames` is absent. | +| `dimensions` | `number` | `1` | Source values stored per cell (any positive integer; values >`4` are packed across multiple texels). | +| `colorMode` | `HealpixColorMode` | `HEALPIX_COLOR_MODE_SCALAR` | How selected values are interpreted. See table below. | +| `filterMin` | `number` | `-Infinity` | Inclusive lower bound. Cells with `valueAt(0) < filterMin` are discarded (scalar modes only). | +| `filterMax` | `number` | `Infinity` | Inclusive upper bound. Cells with `valueAt(0) > filterMax` are discarded (scalar modes only). | +| `rescaleMin` | `number` | `0` | Value mapped to colorMap index 0 (scalar modes only). | +| `rescaleMax` | `number` | `1` | Value mapped to colorMap index 255 (scalar modes only). | +| `colorMap` | `Uint8Array` (1024 B) | black → white | 256-entry RGBA LUT used in scalar modes. | +| `frames` | `HealpixFrameObject[]` | — | Optional animation frames; each may override any root field. | +| `currentFrame` | `number` | `0` | Active index into `frames`. Clamped to `[0, frames.length - 1]`. | +| `shaderModules` | `ShaderModule[]` | `[]` | Custom luma.gl shader modules appended after the built-in pipeline. Root-only (cannot be set per frame). | + +### `colorMode` modes + +| Constant | Interpretation | +| --------------------------------- | --------------------------------------------------------------------------------------------- | +| `HEALPIX_COLOR_MODE_SCALAR` | `valueAt(0)` → filter → rescale → colorMap LUT → RGBA. Alpha = `1`. | +| `HEALPIX_COLOR_MODE_SCALAR_ALPHA` | `valueAt(0)` → filter → rescale → colorMap LUT, then multiplied by `valueAt(1)` as alpha. | +| `HEALPIX_COLOR_MODE_RGB` | Direct `vec4(valueAt(0), valueAt(1), valueAt(2), 1)`. Filter / rescale / colorMap ignored. | +| `HEALPIX_COLOR_MODE_RGBA` | Direct `vec4(valueAt(0..3))`. Filter / rescale / colorMap ignored. | `values` is always an interleaved flat array: cell `i` occupies indices `i * dimensions` through `i * dimensions + dimensions - 1`. +`dimensions` controls texture packing only — it is decoupled from `colorMode`. You can store ten bands per cell (`dimensions: 10`) and still pick which channels the renderer consumes via a custom `HEALPIX_SELECT_VALUES` injection (see below). + +### Custom shader modules + +The primitive layer registers two custom fragment-shader hooks: + +- `fs:HEALPIX_SELECT_VALUES(inout vec4 selectedValues, FragmentGeometry geometry)` — runs after the default selection (`channels 0..3`), before filter/rescale. Use it to compute derived values (NDVI, classification, etc.) and write into `healpixSelectedValues`. Calling `discard;` here drops the cell entirely (including from picking). +- `fs:HEALPIX_RESCALE_VALUES(inout vec4 selectedValues, FragmentGeometry geometry)` — runs after the built-in rescale, before the colorMap lookup. Use it to apply gamma, sigmoid, or any final scalar transform. + +Inside the hooks the values module exposes: + +```glsl +int healpixCell; // current cell index +int healpixDimensions; // source channel count +int healpixColorMode; // active HEALPIX_COLOR_MODE_* +vec4 healpixSelectedValues; // working selection (mutable) +float healpixValueAt(int channel); // any channel in [0, healpixDimensions) +``` + +Pass custom modules via the root-level `shaderModules` prop: + +```ts +import { + HealpixCellsLayer, + HEALPIX_COLOR_MODE_SCALAR +} from '@developmentseed/deck.gl-healpix'; + +const ndviSelector = { + name: 'ndviSelector', + inject: { + 'fs:HEALPIX_SELECT_VALUES': `\ +float nir = healpixValueAt(7); +float red = healpixValueAt(3); +float ndvi = (nir - red) / max(nir + red, 1e-6); +healpixSelectedValues = vec4(ndvi, 0.0, 0.0, 0.0); +` + } +}; + +const gammaRescale = { + name: 'gammaRescale', + inject: { + 'fs:HEALPIX_RESCALE_VALUES': `\ +healpixSelectedValues.x = pow(clamp(healpixSelectedValues.x, 0.0, 1.0), 0.5); +` + } +}; + +new HealpixCellsLayer({ + id: 'ndvi', + nside, + cellIds, + values, // 10 bands per cell + dimensions: 10, + colorMode: HEALPIX_COLOR_MODE_SCALAR, + rescaleMin: -1, + rescaleMax: 1, + colorMap, + shaderModules: [ndviSelector, gammaRescale] +}); +``` + +The fragment pipeline is: + +``` +healpixValues → default select (channels 0..3) + → fs:HEALPIX_SELECT_VALUES (user, optional) + → healpixFilter (scalar modes) + → healpixRescale (scalar modes) + → fs:HEALPIX_RESCALE_VALUES (user, optional) + → healpixColor (colorMap or direct RGB/RGBA) + → fragColor +``` + ### `HealpixFrameObject` -Every field is optional and falls back to the matching root-level prop. `values` is the only field that must be set somewhere (root or frame). +Every field is optional and falls back to the matching root-level prop. `values` is the only field that must be set somewhere (root or frame). `shaderModules` is root-only. ```ts type HealpixFrameObject = { @@ -150,27 +258,16 @@ type HealpixFrameObject = { scheme?: 'nest' | 'ring'; cellIds?: CellIdArray; values?: ArrayLike; - min?: number; - max?: number; - dimensions?: 1 | 2 | 3 | 4; + dimensions?: number; + colorMode?: HealpixColorMode; + filterMin?: number; + filterMax?: number; + rescaleMin?: number; + rescaleMax?: number; colorMap?: Uint8Array; }; ``` -### Geometry worker - -Cell polygon geometry is computed on the CPU in a Web Worker pool. If the default worker loader does not work for your bundler you can supply a custom factory: - -```ts -import { setWorkerFactory, setWorkerUrl } from '@developmentseed/deck.gl-healpix'; - -// Provide an explicit worker URL … -setWorkerUrl(new URL('@developmentseed/deck.gl-healpix/worker', import.meta.url)); - -// … or supply a custom factory that returns a ready-to-use Worker instance. -setWorkerFactory(() => new Worker(/* ... */)); -``` - ### `makeColorMap(getColor)` Build a 256-entry RGBA colorMap (1024 bytes) from a callback. @@ -192,13 +289,21 @@ The callback receives `(t: number, index: number)` where `t = index / 255` in `[ Values outside their valid range are clamped. -### Types +### Types and constants ```ts +import { + HEALPIX_COLOR_MODE_SCALAR, + HEALPIX_COLOR_MODE_SCALAR_ALPHA, + HEALPIX_COLOR_MODE_RGB, + HEALPIX_COLOR_MODE_RGBA +} from '@developmentseed/deck.gl-healpix'; + import type { HealpixCellsLayerProps, HealpixFrameObject, HealpixScheme, + HealpixColorMode, CellIdArray, ColorMapCallbackValue } from '@developmentseed/deck.gl-healpix'; @@ -206,6 +311,7 @@ import type { - **`HealpixScheme`** — `'nest' | 'ring'` - **`CellIdArray`** — `Int32Array | Uint32Array | Float32Array | Float64Array` +- **`HealpixColorMode`** — Union of the four `HEALPIX_COLOR_MODE_*` integer constants. - **`HealpixCellsLayerProps`** — Full prop type for the layer. - **`HealpixFrameObject`** — One animation frame; see above. - **`ColorMapCallbackValue`** — Return type accepted by the `makeColorMap` callback. From 177c780347cd4465080f33e9da930cd2b97f0535 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 5 May 2026 16:54:39 +0100 Subject: [PATCH 10/24] Clarify `inout` parameter usage in HEALPix shader hooks The README and shader module comments are updated to emphasize that users extending the color pipeline via `fs:HEALPIX_SELECT_VALUES` or `fs:HEALPIX_RESCALE_VALUES` must write to the `selectedValues` `inout` parameter. Writing to the global `healpixSelectedValues` variable within these hooks would result in silent overwrites. Examples in the README are also corrected to reflect this guidance. --- packages/deck.gl-healpix/README.md | 13 +++++++------ .../src/shaders/healpix-values-shader-module.ts | 4 ++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/deck.gl-healpix/README.md b/packages/deck.gl-healpix/README.md index 7a0a6ee..c49af36 100644 --- a/packages/deck.gl-healpix/README.md +++ b/packages/deck.gl-healpix/README.md @@ -178,18 +178,19 @@ A `CompositeLayer` that renders HEALPix cells as filled polygons whose colors ar ### Custom shader modules -The primitive layer registers two custom fragment-shader hooks: +The primitive layer registers two custom fragment-shader hooks (deck.gl-style `inout` signatures): -- `fs:HEALPIX_SELECT_VALUES(inout vec4 selectedValues, FragmentGeometry geometry)` — runs after the default selection (`channels 0..3`), before filter/rescale. Use it to compute derived values (NDVI, classification, etc.) and write into `healpixSelectedValues`. Calling `discard;` here drops the cell entirely (including from picking). +- `fs:HEALPIX_SELECT_VALUES(inout vec4 selectedValues, FragmentGeometry geometry)` — runs after the default selection (`channels 0..3`), before filter/rescale. Use it to compute derived values (NDVI, classification, etc.). Calling `discard;` here drops the cell entirely (including from picking). - `fs:HEALPIX_RESCALE_VALUES(inout vec4 selectedValues, FragmentGeometry geometry)` — runs after the built-in rescale, before the colorMap lookup. Use it to apply gamma, sigmoid, or any final scalar transform. -Inside the hooks the values module exposes: +> **Important — write to `selectedValues`.** GLSL `inout` is copy-in / copy-out. The hook body receives a local copy of the global as `selectedValues`; on return, that local is written back over the global. Writing directly to `healpixSelectedValues` from inside a hook body is silently overwritten by the unchanged parameter on function return. + +Inside the hooks the values module exposes (in addition to the `selectedValues` parameter): ```glsl int healpixCell; // current cell index int healpixDimensions; // source channel count int healpixColorMode; // active HEALPIX_COLOR_MODE_* -vec4 healpixSelectedValues; // working selection (mutable) float healpixValueAt(int channel); // any channel in [0, healpixDimensions) ``` @@ -208,7 +209,7 @@ const ndviSelector = { float nir = healpixValueAt(7); float red = healpixValueAt(3); float ndvi = (nir - red) / max(nir + red, 1e-6); -healpixSelectedValues = vec4(ndvi, 0.0, 0.0, 0.0); +selectedValues = vec4(ndvi, 0.0, 0.0, 0.0); ` } }; @@ -217,7 +218,7 @@ const gammaRescale = { name: 'gammaRescale', inject: { 'fs:HEALPIX_RESCALE_VALUES': `\ -healpixSelectedValues.x = pow(clamp(healpixSelectedValues.x, 0.0, 1.0), 0.5); +selectedValues.x = pow(clamp(selectedValues.x, 0.0, 1.0), 0.5); ` } }; diff --git a/packages/deck.gl-healpix/src/shaders/healpix-values-shader-module.ts b/packages/deck.gl-healpix/src/shaders/healpix-values-shader-module.ts index 0404a3d..61acb87 100644 --- a/packages/deck.gl-healpix/src/shaders/healpix-values-shader-module.ts +++ b/packages/deck.gl-healpix/src/shaders/healpix-values-shader-module.ts @@ -36,6 +36,10 @@ vec4 healpixSelectedValues; // the no-op bodies (and any user injections) after the deck.gl hook bodies, // but our injections call them from inside DECKGL_FILTER_COLOR. GLSL requires // a declaration before the call site, so declare them here. +// +// The hooks follow the deck.gl DECKGL_FILTER_COLOR pattern: inout vec4 is +// the working selection. Hook bodies must mutate the parameter +// (selectedValues). void HEALPIX_SELECT_VALUES(inout vec4 selectedValues, FragmentGeometry geometry); void HEALPIX_RESCALE_VALUES(inout vec4 selectedValues, FragmentGeometry geometry); From f451c54f12d02a2494340af40147659f136439b7 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 5 May 2026 16:55:00 +0100 Subject: [PATCH 11/24] Update examples to use shader modules to select the bands --- examples/sandbox/app/pages/color/index.tsx | 153 +++++++++++------- .../sandbox/app/pages/color/sentinel-zarr.ts | 12 +- 2 files changed, 104 insertions(+), 61 deletions(-) diff --git a/examples/sandbox/app/pages/color/index.tsx b/examples/sandbox/app/pages/color/index.tsx index 9a7d2fb..d245faa 100644 --- a/examples/sandbox/app/pages/color/index.tsx +++ b/examples/sandbox/app/pages/color/index.tsx @@ -18,10 +18,9 @@ import { import { DeckGlOverlay } from '$shared/components/deckgl-overlay'; import { + COMPOSITE_COLS, + RGB_NUM, type SentinelHealpixZarr, - buildCompositeRgb, - buildNdvi, - extractColumn, loadSentinelHealpixZarr } from './sentinel-zarr'; import { BAND_CHOICES, BAND_INDEX, BandLabel } from './sentinel-zarr-bands'; @@ -139,69 +138,37 @@ export default function PageColor() { [colorScheme] ); + const selectorModule = useSelectorModule(visualization); + const layers = useMemo(() => { if (!zarrData) return []; - const { nside, cellIds } = zarrData; - if (isNdviMode(visualization)) { - const values = buildNdvi(zarrData); - return [ - new HealpixCellsLayer({ - id: 'healpix-zarr-ndvi', - nside, - scheme: 'nest', - cellIds, - values, - dimensions: 1, - colorMode: HEALPIX_COLOR_MODE_SCALAR, - rescaleMin, - rescaleMax, - filterMin, - filterMax, - colorMap - }) - ]; - } - if (isSingleBandMode(visualization)) { - const col = BAND_INDEX[visualization]; - const values = extractColumn(zarrData, col); - return [ - new HealpixCellsLayer({ - id: 'healpix-zarr-band', - nside, - scheme: 'nest', - cellIds, - values, - dimensions: 1, - colorMode: HEALPIX_COLOR_MODE_SCALAR, - rescaleMin, - rescaleMax, - filterMin, - filterMax, - colorMap - }) - ]; - } - const values = buildCompositeRgb( - visualization as 'true_color' | 'infrared_false_color' | 'swir', - zarrData - ); + const { nside, cellIds, valuesFlat, nbands } = zarrData; + const isRgbComposite = !ndvi && !singleBand; + return [ new HealpixCellsLayer({ - id: 'healpix-zarr-rgb', + id: 'healpix-zarr', nside, scheme: 'nest', cellIds, - values, - dimensions: 3, - colorMode: HEALPIX_COLOR_MODE_RGB, - min: 0, - max: 1, - colorMap + values: valuesFlat, + dimensions: nbands, + colorMode: isRgbComposite + ? HEALPIX_COLOR_MODE_RGB + : HEALPIX_COLOR_MODE_SCALAR, + rescaleMin, + rescaleMax, + filterMin, + filterMax, + colorMap, + shaderModules: [selectorModule] }) ]; }, [ zarrData, - visualization, + ndvi, + singleBand, + selectorModule, colorMap, rescaleMin, rescaleMax, @@ -231,10 +198,10 @@ export default function PageColor() { Sentinel 2 scene with 10 bands in healpix
- Cell coloring is computed on the GPU from the selected source - values and color mode. RGB visualizations render selected values - directly, while scalar visualizations map through a color scheme - after optional filtering and rescaling. + Cell coloring is computed on the GPU from the selected source values + and color mode. RGB visualizations render selected values directly, + while scalar visualizations map through a color scheme after + optional filtering and rescaling. @@ -380,3 +347,71 @@ export default function PageColor() {
); } + +function useSelectorModule(visualization: BandVisualizationMode) { + // Per-visualization fragment-shader injection. + // + // The 10 raw band values are uploaded to the GPU once via `dimensions: NBANDS` + // (see the layer config below). For each visualization mode we build a small + // shader module that injects into `fs:HEALPIX_SELECT_VALUES`, picks the + // relevant channels with `healpixValueAt(...)`, and writes the result into + // the `selectedValues` inout parameter. The downstream filter / rescale / + // colorMap stages then consume that vec4. + // + // The hook signature is + // fs:HEALPIX_SELECT_VALUES(inout vec4 selectedValues, FragmentGeometry geometry) + // so write to `selectedValues` (the parameter). + // + // Alternative — pre-select on the JavaScript side. + // Writing GLSL is not required: the `extractColumn`, `buildCompositeRgb`, + // and `buildNdvi` helpers in `./sentinel-zarr.ts` show the equivalent + // CPU-side approach (build a smaller `Float32Array` per mode with + // `dimensions: 1` or `3` and upload that). The shader-hook approach is + // preferred here because it uploads the full multi-band texture once and + // switches visualization without re-uploading anything to the GPU. + return useMemo(() => { + if (isNdviMode(visualization)) { + return { + name: 'healpixSelector_ndvi', + inject: { + 'fs:HEALPIX_SELECT_VALUES': `\ +float red = healpixValueAt(${BAND_INDEX.b04}); +float nir = healpixValueAt(${BAND_INDEX.b8a}); +float denom = max(red + nir, 1e-6); +float ndvi = (nir - red) / denom; +selectedValues = vec4(ndvi, 0.0, 0.0, 0.0); +` + } + }; + } + + if (isSingleBandMode(visualization)) { + const col = BAND_INDEX[visualization]; + return { + name: `healpixSelector_band_${visualization}`, + inject: { + 'fs:HEALPIX_SELECT_VALUES': `\ +selectedValues = vec4(healpixValueAt(${col}), 0.0, 0.0, 0.0); +` + } + }; + } + + const cols = COMPOSITE_COLS[visualization as keyof typeof COMPOSITE_COLS]; + const stretch = (1 / RGB_NUM).toFixed(6); + return { + name: `healpixSelector_${visualization}`, + inject: { + 'fs:HEALPIX_SELECT_VALUES': `\ +const float kStretch = ${stretch}; +selectedValues = vec4( + clamp(healpixValueAt(${cols.r}) * kStretch, 0.0, 1.0), + clamp(healpixValueAt(${cols.g}) * kStretch, 0.0, 1.0), + clamp(healpixValueAt(${cols.b}) * kStretch, 0.0, 1.0), + 1.0 +); +` + } + }; + }, [visualization]); +} diff --git a/examples/sandbox/app/pages/color/sentinel-zarr.ts b/examples/sandbox/app/pages/color/sentinel-zarr.ts index 3a4dfbb..fe805ba 100644 --- a/examples/sandbox/app/pages/color/sentinel-zarr.ts +++ b/examples/sandbox/app/pages/color/sentinel-zarr.ts @@ -1,6 +1,13 @@ /** * Zarr layout: `values` float32 [npix, NBANDS] (row-major), `cell_id` int64 [npix]. * Column order matches `attributes.bands` in the group zarr.json (see `BAND_ORDER`). + * + * The `extractColumn`, `buildCompositeRgb`, and `buildNdvi` helpers below + * pre-select / derive per-cell values on the JavaScript side. The current page + * (`./index.tsx`) instead uploads all 10 bands once and picks channels in the + * fragment shader via the `HEALPIX_SELECT_VALUES` hook; the JS helpers are + * kept here as a documented alternative for callers that prefer to compute + * values on the CPU (see the note in `./index.tsx`). */ import * as zarr from 'zarrita'; import { BAND_INDEX, NBANDS } from './sentinel-zarr-bands'; @@ -60,7 +67,8 @@ export function extractColumn( return out; } -const RGB_NUM = 0.3; +/** Reflectance value mapped to RGB output `1.0` for the composite stretches. */ +export const RGB_NUM = 0.3; function clamp01(x: number): number { return Math.min(1, Math.max(0, x)); @@ -70,7 +78,7 @@ function stretchRgb(x: number): number { return clamp01(x / RGB_NUM); } -const COMPOSITE_COLS: Record< +export const COMPOSITE_COLS: Record< 'true_color' | 'infrared_false_color' | 'swir', { r: number; g: number; b: number } > = { From 1f3ec45dd87b66230f303bb1cbdc8be934b69c88 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Wed, 6 May 2026 11:27:32 +0100 Subject: [PATCH 12/24] Remove superpower plans The specs are what's important for documenting --- .gitignore | 2 + .../plans/2026-04-15-gpu-color-computation.md | 1293 --------- .../plans/2026-04-16-gpu-xyf2loc.md | 687 ----- .../plans/2026-04-21-healpix-gpu-decode.md | 2513 ----------------- .../2026-04-29-healpix-cell-color-pipeline.md | 467 --- 5 files changed, 2 insertions(+), 4960 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-15-gpu-color-computation.md delete mode 100644 docs/superpowers/plans/2026-04-16-gpu-xyf2loc.md delete mode 100644 docs/superpowers/plans/2026-04-21-healpix-gpu-decode.md delete mode 100644 docs/superpowers/plans/2026-04-29-healpix-cell-color-pipeline.md diff --git a/.gitignore b/.gitignore index 5f13257..fa10117 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ yarn-error.log* # Auto-generated for GPU readback tests (`npm run gen:gpu-shaders`) test/gpu/shader-chunks.gen.mjs + +docs/superpowers/plans \ No newline at end of file diff --git a/docs/superpowers/plans/2026-04-15-gpu-color-computation.md b/docs/superpowers/plans/2026-04-15-gpu-color-computation.md deleted file mode 100644 index 1093fd5..0000000 --- a/docs/superpowers/plans/2026-04-15-gpu-color-computation.md +++ /dev/null @@ -1,1293 +0,0 @@ -# HEALPix GPU Color Computation — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the CPU pre-baked `colorFrames` API with a `frames` / `values` API that computes per-cell colors entirely on the GPU using a float values texture and a colorMap LUT. - -**Architecture:** A new `resolveFrame()` utility merges root props with the current frame object and validates inputs. `packValuesData()` packs interleaved float values into an `RGBA32F` texture layout. A new `HealpixColorExtension` injects GLSL into `DECKGL_FILTER_COLOR` to sample the values texture and compute color via a 256×1 colorMap texture. The composite layer does smart change detection — only rebuilding geometry, values texture, or colorMap texture when their specific inputs change. - -**Tech Stack:** TypeScript, deck.gl composite layer / extension pattern, luma.gl v9 textures, GLSL shader injection via `vs:#decl` + `vs:DECKGL_FILTER_COLOR`. - ---- - -## File Map - -| Action | Path | Responsibility | -|--------|------|----------------| -| Modify | `src/types/layer-props.ts` | Add `HealpixFrameObject`, update `HealpixCellsLayerProps` | -| Create | `src/utils/color-map.ts` | `DEFAULT_COLORMAP`, `validateColorMap()` | -| Create | `src/utils/color-map.test.ts` | Tests for color-map utilities | -| Create | `src/utils/resolve-frame.ts` | `resolveFrame()`, `ResolvedFrame` type | -| Create | `src/utils/resolve-frame.test.ts` | Tests for frame resolution | -| Create | `src/utils/values-texture.ts` | `packValuesData()` — pure CPU texture packing | -| Create | `src/utils/values-texture.test.ts` | Tests for values packing | -| Create | `src/extensions/healpix-color-shader-module.ts` | luma.gl shader module: scalar uniforms | -| Create | `src/extensions/healpix-color-extension.ts` | `HealpixColorExtension` + GLSL injection | -| Modify | `src/layers/healpix-cells-layer.ts` | Frame resolution, smart change detection, texture management | -| Modify | `src/index.ts` | Export `HealpixFrameObject`, remove `makeColorFrameFromValues` | -| Delete | `src/extensions/healpix-color-frames-extension.ts` | Replaced by `healpix-color-extension.ts` | -| Delete | `src/extensions/healpix-color-frames-shader-module.ts` | Replaced by `healpix-color-shader-module.ts` | - ---- - -## Task 1: Update Types - -**Files:** -- Modify: `src/types/layer-props.ts` - -- [ ] **Step 1: Replace the file contents** - -```typescript -import type { CompositeLayerProps } from '@deck.gl/core'; -import type { CellIdArray } from './cell-ids'; - -/** HEALPix pixel numbering scheme. */ -export type HealpixScheme = 'nest' | 'ring'; - -export type { CellIdArray }; - -/** - * One animation frame of HEALPix cell data. - * - * All fields are optional — any field not set here falls back to the - * matching root-level prop on `HealpixCellsLayerProps`. - * - * `values` is the only field with no root-level equivalent: it must be - * present either here or at the root (via `HealpixCellsLayerProps.values`). - * - * ## `values` layout - * - * `values` is an interleaved flat array. Cell `i` occupies indices - * `i * dimensions` through `i * dimensions + dimensions - 1`. - * - * ## `dimensions` interpretation - * - * | `dimensions` | Interpretation | - * |---|---| - * | `1` | Scalar → normalized through `[min, max]` → colorMap LUT → RGBA | - * | `2` | Scalar (→ colorMap) + opacity multiplier (0–1) | - * | `3` | Direct RGB in range 0–1; colorMap/min/max ignored; alpha = 1 | - * | `4` | Direct RGBA in range 0–1; colorMap/min/max ignored | - * - * Values beyond 4 dimensions are reserved for future band math. Cells - * with `dimensions > 4` render as transparent with a console warning. - */ -export type HealpixFrameObject = { - /** Overrides root `nside`. */ - nside?: number; - /** Overrides root `scheme`. Default: `'nest'`. */ - scheme?: HealpixScheme; - /** Overrides root `cellIds`. */ - cellIds?: CellIdArray; - /** - * Per-cell float values. Interleaved: cell `i` starts at index - * `i * dimensions`. Length must equal `cellIds.length * dimensions`. - */ - values?: ArrayLike; - /** Overrides root `min`. Default: `0`. */ - min?: number; - /** Overrides root `max`. Default: `1`. */ - max?: number; - /** - * Number of values per cell. Controls color computation mode. - * Default: `1`. See type-level docs for full interpretation table. - */ - dimensions?: 1 | 2 | 3 | 4; - /** - * ColorMap LUT: exactly 256 × 4 = 1024 RGBA bytes. - * Index 0 maps to `min`, index 255 maps to `max`. - * Default: linear black → white gradient. - */ - colorMap?: Uint8Array; -}; - -/** - * Props for `HealpixCellsLayer`. - * - * ## Single-frame usage - * - * Omit `frames` and set `nside`, `cellIds`, and `values` directly: - * - * ```tsx - * - * ``` - * - * ## Multi-frame usage - * - * Root-level props act as shared defaults. Each `frames` entry overrides - * selectively. Switch frames by updating `currentFrame`. - * - * ```tsx - * - * ``` - * - * ## Color dimensions - * - * See `HealpixFrameObject` for the full `dimensions` interpretation table. - */ -export type HealpixCellsLayerProps = { - /** - * HEALPix resolution parameter (power of 2, up to 262144). - * Required at render time: set here or on every frame object. - */ - nside: number; - /** - * HEALPix cell indices. - * Required at render time: set here or on every frame object. - */ - cellIds: CellIdArray; - /** Numbering scheme. Default: `'nest'`. */ - scheme?: HealpixScheme; - /** - * Per-cell values for single-frame mode (when `frames` is absent). - * Interleaved: cell `i` starts at index `i * dimensions`. - * Length must equal `cellIds.length * dimensions`. - */ - values?: ArrayLike; - /** Value mapped to colorMap index 0. Default: `0`. */ - min?: number; - /** Value mapped to colorMap index 255. Default: `1`. */ - max?: number; - /** - * Number of values per cell. Default: `1`. - * See `HealpixFrameObject` for the full interpretation table. - */ - dimensions?: 1 | 2 | 3 | 4; - /** - * ColorMap LUT: exactly 256 × 4 = 1024 RGBA bytes (default: black→white). - * Used as a shared default when frames do not provide their own colorMap. - */ - colorMap?: Uint8Array; - /** Animation frames. When absent, the layer renders a single frame from root props. */ - frames?: HealpixFrameObject[]; - /** Active frame index into `frames`. Clamped to `[0, frames.length - 1]`. Default: `0`. */ - currentFrame?: number; -} & CompositeLayerProps; -``` - -- [ ] **Step 2: Run tests — all 26 must still pass** - -```bash -cd .worktrees/gpu-color-geometry && npm test -``` - -Expected: `26 passed`. - -- [ ] **Step 3: Commit** - -```bash -git add src/types/layer-props.ts -git commit -m "feat: add HealpixFrameObject and update HealpixCellsLayerProps for GPU color API" -``` - ---- - -## Task 2: `color-map` Utility - -**Files:** -- Create: `src/utils/color-map.ts` -- Create: `src/utils/color-map.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `src/utils/color-map.test.ts`: - -```typescript -import { DEFAULT_COLORMAP, validateColorMap } from './color-map'; - -describe('DEFAULT_COLORMAP', () => { - it('is exactly 1024 bytes (256 × 4)', () => { - expect(DEFAULT_COLORMAP.length).toBe(1024); - }); - - it('starts with black (0,0,0,255)', () => { - expect(DEFAULT_COLORMAP[0]).toBe(0); - expect(DEFAULT_COLORMAP[1]).toBe(0); - expect(DEFAULT_COLORMAP[2]).toBe(0); - expect(DEFAULT_COLORMAP[3]).toBe(255); - }); - - it('ends with white (255,255,255,255)', () => { - expect(DEFAULT_COLORMAP[1020]).toBe(255); - expect(DEFAULT_COLORMAP[1021]).toBe(255); - expect(DEFAULT_COLORMAP[1022]).toBe(255); - expect(DEFAULT_COLORMAP[1023]).toBe(255); - }); - - it('has a linear gray gradient', () => { - expect(DEFAULT_COLORMAP[128 * 4 + 0]).toBe(128); - expect(DEFAULT_COLORMAP[128 * 4 + 1]).toBe(128); - expect(DEFAULT_COLORMAP[128 * 4 + 2]).toBe(128); - expect(DEFAULT_COLORMAP[128 * 4 + 3]).toBe(255); - }); -}); - -describe('validateColorMap', () => { - it('does not throw for exactly 1024 bytes', () => { - expect(() => validateColorMap(new Uint8Array(1024))).not.toThrow(); - }); - - it('throws for wrong length with a message mentioning 1024', () => { - expect(() => validateColorMap(new Uint8Array(100))).toThrow('1024'); - expect(() => validateColorMap(new Uint8Array(0))).toThrow('1024'); - expect(() => validateColorMap(new Uint8Array(1025))).toThrow('1024'); - }); -}); -``` - -- [ ] **Step 2: Run to verify they fail** - -```bash -npx jest src/utils/color-map.test.ts -``` - -Expected: `Cannot find module './color-map'`. - -- [ ] **Step 3: Implement `src/utils/color-map.ts`** - -```typescript -/** - * Default colorMap: linear black (0,0,0,255) → white (255,255,255,255) gradient. - * 256 entries × 4 bytes (RGBA) = 1024 bytes. - */ -export const DEFAULT_COLORMAP: Uint8Array = (() => { - const map = new Uint8Array(256 * 4); - for (let i = 0; i < 256; i++) { - map[i * 4 + 0] = i; - map[i * 4 + 1] = i; - map[i * 4 + 2] = i; - map[i * 4 + 3] = 255; - } - return map; -})(); - -/** - * Validates that `colorMap` is exactly 256 × 4 = 1024 bytes. - * Throws with a descriptive message if not. - */ -export function validateColorMap(colorMap: Uint8Array): void { - if (colorMap.length !== 1024) { - throw new Error( - `HealpixCellsLayer: colorMap must be exactly 256 × 4 = 1024 bytes, got ${colorMap.length}` - ); - } -} -``` - -- [ ] **Step 4: Run tests — must pass** - -```bash -npx jest src/utils/color-map.test.ts -``` - -Expected: `4 passed`. - -- [ ] **Step 5: Commit** - -```bash -git add src/utils/color-map.ts src/utils/color-map.test.ts -git commit -m "feat: add DEFAULT_COLORMAP and validateColorMap utilities" -``` - ---- - -## Task 3: `resolve-frame` Utility - -**Files:** -- Create: `src/utils/resolve-frame.ts` -- Create: `src/utils/resolve-frame.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `src/utils/resolve-frame.test.ts`: - -```typescript -import { resolveFrame } from './resolve-frame'; -import { DEFAULT_COLORMAP } from './color-map'; -import type { HealpixCellsLayerProps } from '../types/layer-props'; - -const validIds = new Uint32Array([1, 2, 3]); -const validValues = new Float32Array([0.1, 0.2, 0.3]); // dim=1, 3 cells - -function makeProps(overrides: Partial): HealpixCellsLayerProps { - return { nside: 64, cellIds: validIds, values: validValues, ...overrides } as HealpixCellsLayerProps; -} - -describe('resolveFrame — single-frame mode (no frames array)', () => { - it('uses root props and applies defaults', () => { - const result = resolveFrame(makeProps({})); - expect(result.nside).toBe(64); - expect(result.cellIds).toBe(validIds); - expect(result.values).toBe(validValues); - expect(result.scheme).toBe('nest'); - expect(result.min).toBe(0); - expect(result.max).toBe(1); - expect(result.dimensions).toBe(1); - expect(result.colorMap).toBe(DEFAULT_COLORMAP); - }); - - it('uses explicit root overrides', () => { - const myColorMap = new Uint8Array(1024); - const result = resolveFrame(makeProps({ - scheme: 'ring', - min: -1, - max: 10, - dimensions: 3, - colorMap: myColorMap, - values: new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]), // 3 cells × 3 dims - })); - expect(result.scheme).toBe('ring'); - expect(result.min).toBe(-1); - expect(result.max).toBe(10); - expect(result.dimensions).toBe(3); - expect(result.colorMap).toBe(myColorMap); - }); -}); - -describe('resolveFrame — multi-frame mode', () => { - it('uses the current frame at currentFrame index', () => { - const vals0 = new Float32Array([0.1, 0.2, 0.3]); - const vals1 = new Float32Array([0.4, 0.5, 0.6]); - const result = resolveFrame(makeProps({ - frames: [{ values: vals0 }, { values: vals1 }], - currentFrame: 1, - })); - expect(result.values).toBe(vals1); - }); - - it('frame fields override root props', () => { - const frameIds = new Uint32Array([9, 8]); - const frameVals = new Float32Array([0.5, 0.6]); - const result = resolveFrame(makeProps({ - frames: [{ cellIds: frameIds, values: frameVals, min: -5, max: 5 }], - currentFrame: 0, - })); - expect(result.cellIds).toBe(frameIds); - expect(result.values).toBe(frameVals); - expect(result.min).toBe(-5); - expect(result.max).toBe(5); - }); - - it('root props fill gaps not set on frame', () => { - const myColorMap = new Uint8Array(1024); - const result = resolveFrame(makeProps({ - colorMap: myColorMap, - frames: [{ values: validValues }], - currentFrame: 0, - })); - expect(result.colorMap).toBe(myColorMap); - expect(result.nside).toBe(64); // from root - }); - - it('clamps currentFrame to [0, frames.length - 1]', () => { - const vals0 = new Float32Array([0.1, 0.2, 0.3]); - const vals1 = new Float32Array([0.4, 0.5, 0.6]); - const props = makeProps({ - frames: [{ values: vals0 }, { values: vals1 }], - }); - - expect(resolveFrame({ ...props, currentFrame: 99 }).values).toBe(vals1); - expect(resolveFrame({ ...props, currentFrame: -5 }).values).toBe(vals0); - }); - - it('empty frames array falls back to root props', () => { - const result = resolveFrame(makeProps({ frames: [] })); - expect(result.values).toBe(validValues); - }); -}); - -describe('resolveFrame — validation', () => { - it('throws if nside is missing', () => { - expect(() => - resolveFrame({ cellIds: validIds, values: validValues } as HealpixCellsLayerProps) - ).toThrow(/nside/); - }); - - it('throws if cellIds is missing', () => { - expect(() => - resolveFrame({ nside: 64, values: validValues } as HealpixCellsLayerProps) - ).toThrow(/cellIds/); - }); - - it('throws if values is missing', () => { - expect(() => - resolveFrame({ nside: 64, cellIds: validIds } as HealpixCellsLayerProps) - ).toThrow(/values/); - }); - - it('throws if colorMap has wrong length', () => { - expect(() => - resolveFrame(makeProps({ colorMap: new Uint8Array(100) })) - ).toThrow(/1024/); - }); - - it('throws if values.length !== cellIds.length * dimensions', () => { - expect(() => - resolveFrame(makeProps({ values: new Float32Array([0.1, 0.2]) })) // 2 values, 3 cells × dim=1 - ).toThrow(/values\.length/); - }); -}); -``` - -- [ ] **Step 2: Run to verify they fail** - -```bash -npx jest src/utils/resolve-frame.test.ts -``` - -Expected: `Cannot find module './resolve-frame'`. - -- [ ] **Step 3: Implement `src/utils/resolve-frame.ts`** - -```typescript -import type { HealpixCellsLayerProps, HealpixFrameObject } from '../types/layer-props'; -import type { CellIdArray } from '../types/cell-ids'; -import { DEFAULT_COLORMAP, validateColorMap } from './color-map'; - -/** The fully resolved, validated frame ready for GPU upload. */ -export type ResolvedFrame = { - nside: number; - scheme: 'nest' | 'ring'; - cellIds: CellIdArray; - values: ArrayLike; - min: number; - max: number; - dimensions: 1 | 2 | 3 | 4; - colorMap: Uint8Array; -}; - -/** - * Resolve the effective frame from layer props. - * - * When `props.frames` is set and non-empty, the frame at `props.currentFrame` - * (clamped to valid range) is merged on top of root props. When `frames` is - * absent or empty, root props are used directly. - * - * Throws with a descriptive message if required fields are missing or invalid. - */ -export function resolveFrame(props: HealpixCellsLayerProps): ResolvedFrame { - // Determine which frame object to use (may be empty object for single-frame mode) - let frame: Partial = {}; - if (props.frames && props.frames.length > 0) { - const idx = Math.max( - 0, - Math.min(props.frames.length - 1, Math.floor((props.currentFrame ?? 0))) - ); - frame = props.frames[idx] ?? {}; - } - - // nside - const nside = frame.nside ?? props.nside; - if (!nside) { - throw new Error( - 'HealpixCellsLayer: nside is required — set it on the layer or on each frame object' - ); - } - - // cellIds - const cellIds = frame.cellIds ?? props.cellIds; - if (!cellIds) { - throw new Error( - 'HealpixCellsLayer: cellIds is required — set it on the layer or on each frame object' - ); - } - - // values - const values = frame.values ?? props.values; - if (!values) { - throw new Error( - 'HealpixCellsLayer: values is required — set it on the layer or on each frame object' - ); - } - - // colorMap - const colorMap = frame.colorMap ?? props.colorMap ?? DEFAULT_COLORMAP; - validateColorMap(colorMap); // throws if wrong length - - const dimensions = (frame.dimensions ?? props.dimensions ?? 1) as 1 | 2 | 3 | 4; - - // values length check - const expectedLen = cellIds.length * dimensions; - if (values.length !== expectedLen) { - throw new Error( - `HealpixCellsLayer: values.length (${values.length}) must equal cellIds.length × dimensions (${cellIds.length} × ${dimensions} = ${expectedLen})` - ); - } - - return { - nside, - scheme: frame.scheme ?? props.scheme ?? 'nest', - cellIds: cellIds as CellIdArray, - values, - min: frame.min ?? props.min ?? 0, - max: frame.max ?? props.max ?? 1, - dimensions, - colorMap, - }; -} -``` - -- [ ] **Step 4: Run tests — must pass** - -```bash -npx jest src/utils/resolve-frame.test.ts -``` - -Expected: `14 passed`. - -- [ ] **Step 5: Run full suite** - -```bash -npm test -``` - -Expected: all 26 original + 14 new = `40 passed`. - -- [ ] **Step 6: Commit** - -```bash -git add src/utils/resolve-frame.ts src/utils/resolve-frame.test.ts -git commit -m "feat: add resolveFrame utility with frame merging and validation" -``` - ---- - -## Task 4: `values-texture` Utility - -**Files:** -- Create: `src/utils/values-texture.ts` -- Create: `src/utils/values-texture.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `src/utils/values-texture.test.ts`: - -```typescript -import { packValuesData } from './values-texture'; - -const MAX = 4096; - -describe('packValuesData', () => { - it('dim=1: fills only R channel, G/B/A are 0', () => { - const { data, width, height } = packValuesData([0.5], 1, 1, MAX); - expect(width).toBe(1); - expect(height).toBe(1); - expect(data.length).toBe(4); // 1 texel × 4 floats - expect(data[0]).toBeCloseTo(0.5); // R - expect(data[1]).toBe(0); // G - expect(data[2]).toBe(0); // B - expect(data[3]).toBe(0); // A - }); - - it('dim=2: fills R and G, B/A are 0', () => { - const { data } = packValuesData([0.3, 0.7], 2, 1, MAX); - expect(data[0]).toBeCloseTo(0.3); - expect(data[1]).toBeCloseTo(0.7); - expect(data[2]).toBe(0); - expect(data[3]).toBe(0); - }); - - it('dim=3: fills R, G, B; A is 0', () => { - const { data } = packValuesData([0.1, 0.2, 0.3], 3, 1, MAX); - expect(data[0]).toBeCloseTo(0.1); - expect(data[1]).toBeCloseTo(0.2); - expect(data[2]).toBeCloseTo(0.3); - expect(data[3]).toBe(0); - }); - - it('dim=4: fills all channels', () => { - const { data } = packValuesData([0.1, 0.2, 0.3, 0.4], 4, 1, MAX); - expect(data[0]).toBeCloseTo(0.1); - expect(data[1]).toBeCloseTo(0.2); - expect(data[2]).toBeCloseTo(0.3); - expect(data[3]).toBeCloseTo(0.4); - }); - - it('two cells, dim=1: each in a separate texel', () => { - const { data, width } = packValuesData([0.2, 0.8], 1, 2, MAX); - expect(width).toBe(2); - // cell 0: texel at x=0 → data[0] - expect(data[0]).toBeCloseTo(0.2); - // cell 1: texel at x=1 → data[4] - expect(data[4]).toBeCloseTo(0.8); - }); - - it('multiple cells, dim=2: each cell gets its own texel', () => { - const values = [0.1, 0.2, 0.3, 0.4]; // 2 cells × 2 dims - const { data } = packValuesData(values, 2, 2, MAX); - // cell 0 at texel 0: data[0]=0.1, data[1]=0.2 - expect(data[0]).toBeCloseTo(0.1); - expect(data[1]).toBeCloseTo(0.2); - // cell 1 at texel 1 (offset 4): data[4]=0.3, data[5]=0.4 - expect(data[4]).toBeCloseTo(0.3); - expect(data[5]).toBeCloseTo(0.4); - }); - - it('folds into 2D when cellCount > maxTextureSize', () => { - // 5 cells, maxTextureSize=3 → width=3, height=2 - const values = [0.1, 0.2, 0.3, 0.4, 0.5]; - const { data, width, height } = packValuesData(values, 1, 5, 3); - expect(width).toBe(3); - expect(height).toBe(2); - // cell 3: x=3%3=0, y=floor(3/3)=1, dstBase=(1*3+0)*4=12 - expect(data[12]).toBeCloseTo(0.4); - // cell 4: x=4%3=1, y=floor(4/3)=1, dstBase=(1*3+1)*4=16 - expect(data[16]).toBeCloseTo(0.5); - }); - - it('returns 1×1 zero texel for cellCount=0', () => { - const { data, width, height } = packValuesData([], 1, 0, MAX); - expect(width).toBe(1); - expect(height).toBe(1); - expect(data.length).toBe(4); - expect(data[0]).toBe(0); - }); -}); -``` - -- [ ] **Step 2: Run to verify they fail** - -```bash -npx jest src/utils/values-texture.test.ts -``` - -Expected: `Cannot find module './values-texture'`. - -- [ ] **Step 3: Implement `src/utils/values-texture.ts`** - -```typescript -/** - * Packs per-cell interleaved float values into an `RGBA32F` 2D texture layout. - * - * The texture is folded: cell `i` maps to texel `(i % width, floor(i / width))`. - * Each texel has 4 floats (RGBA32F). Channels 0 through `dimensions-1` are - * filled; the rest remain 0. - * - * @param values Interleaved float values. Length = cellCount × dimensions. - * @param dimensions Number of values per cell (1–4). - * @param cellCount Total number of cells. - * @param maxTextureSize GPU max texture dimension (from device limits). - */ -export function packValuesData( - values: ArrayLike, - dimensions: number, - cellCount: number, - maxTextureSize: number -): { data: Float32Array; width: number; height: number } { - if (cellCount === 0) { - return { data: new Float32Array(4), width: 1, height: 1 }; - } - - const channelCount = Math.min(dimensions, 4); - const width = Math.min(cellCount, maxTextureSize); - const height = Math.ceil(cellCount / width); - const data = new Float32Array(width * height * 4); - - for (let i = 0; i < cellCount; i++) { - const x = i % width; - const y = Math.floor(i / width); - const dstBase = (y * width + x) * 4; - const srcBase = i * dimensions; - for (let d = 0; d < channelCount; d++) { - data[dstBase + d] = (values as number[])[srcBase + d] ?? 0; - } - } - - return { data, width, height }; -} -``` - -- [ ] **Step 4: Run tests — must pass** - -```bash -npx jest src/utils/values-texture.test.ts -``` - -Expected: `9 passed`. - -- [ ] **Step 5: Run full suite** - -```bash -npm test -``` - -Expected: all 40 original + 9 new = `49 passed`. - -- [ ] **Step 6: Commit** - -```bash -git add src/utils/values-texture.ts src/utils/values-texture.test.ts -git commit -m "feat: add packValuesData utility for RGBA32F texture packing" -``` - ---- - -## Task 5: `healpix-color-shader-module.ts` - -**Files:** -- Create: `src/extensions/healpix-color-shader-module.ts` - -- [ ] **Step 1: Create the shader module** - -```typescript -import type { Texture } from '@luma.gl/core'; -import type { ShaderModule } from '@luma.gl/shadertools'; - -/** - * Props consumed by the HEALPix color shader module. - * - * Scalar uniforms go into the `healpixColorUniforms` block. - * Texture bindings (`healpixValuesTexture`, `healpixColorMapTexture`) are - * declared separately in the `vs:#decl` injection in the extension. - */ -export type HealpixColorProps = { - uMin: number; - uMax: number; - uDimensions: number; - uValuesWidth: number; - healpixValuesTexture: Texture; - healpixColorMapTexture: Texture; -}; - -/** - * Shader module for GPU color computation. - * - * Declares the `healpixColor` uniform block (scalar uniforms). - * Textures are bound alongside these props via `model.shaderInputs.setProps`. - */ -export const healpixColorShaderModule = { - name: 'healpixColor', - vs: `\ -uniform healpixColorUniforms { - float uMin; - float uMax; - int uDimensions; - int uValuesWidth; -} healpixColor; -`, - uniformTypes: { - uMin: 'f32', - uMax: 'f32', - uDimensions: 'i32', - uValuesWidth: 'i32', - } -} as const satisfies ShaderModule; -``` - -- [ ] **Step 2: Run full suite — must still pass** - -```bash -npm test -``` - -Expected: `49 passed`. - -- [ ] **Step 3: Commit** - -```bash -git add src/extensions/healpix-color-shader-module.ts -git commit -m "feat: add healpixColor shader module for GPU color uniforms" -``` - ---- - -## Task 6: `HealpixColorExtension` - -**Files:** -- Create: `src/extensions/healpix-color-extension.ts` - -- [ ] **Step 1: Create the extension** - -```typescript -import { Layer, LayerExtension, LayerProps } from '@deck.gl/core'; -import type { Texture } from '@luma.gl/core'; -import { healpixColorShaderModule } from './healpix-color-shader-module'; - -/** Extra props this extension reads from the host primitive layer. */ -export type HealpixColorExtensionProps = LayerProps & { - valuesTexture: Texture; - colorMapTexture: Texture; - uMin: number; - uMax: number; - uDimensions: number; - uValuesWidth: number; -}; - -/** - * GLSL declaration injected into the vertex shader. - * Declares the two texture samplers used for color computation. - */ -const VERTEX_DECLARATION_INJECT = ` -uniform mediump sampler2D healpixValuesTexture; -uniform mediump sampler2D healpixColorMapTexture; -`; - -/** - * GLSL injected into deck.gl's DECKGL_FILTER_COLOR hook. - * - * Samples per-cell float values from the values texture, then computes - * the output color based on the dimensions mode: - * - * dimensions=1 scalar → normalized through [min,max] → colorMap LUT - * dimensions=2 scalar (→ colorMap) + opacity multiplier (second value) - * dimensions=3 direct RGB in 0–1; alpha=1 - * dimensions=4 direct RGBA in 0–1 - * else transparent (reserved for future band math) - */ -const VERTEX_COLOR_FILTER_INJECT = ` -int healpixCell = gl_InstanceID; -int healpixX = healpixCell % healpixColor.uValuesWidth; -int healpixY = healpixCell / healpixColor.uValuesWidth; -vec4 healpixVals = texelFetch(healpixValuesTexture, ivec2(healpixX, healpixY), 0); - -vec4 healpixOut; -if (healpixColor.uDimensions == 1) { - float t = clamp( - (healpixVals.r - healpixColor.uMin) / (healpixColor.uMax - healpixColor.uMin), - 0.0, 1.0 - ); - healpixOut = texelFetch(healpixColorMapTexture, ivec2(int(t * 255.0), 0), 0); -} else if (healpixColor.uDimensions == 2) { - float t = clamp( - (healpixVals.r - healpixColor.uMin) / (healpixColor.uMax - healpixColor.uMin), - 0.0, 1.0 - ); - healpixOut = texelFetch(healpixColorMapTexture, ivec2(int(t * 255.0), 0), 0); - healpixOut.a *= healpixVals.g; -} else if (healpixColor.uDimensions == 3) { - healpixOut = vec4(healpixVals.rgb, 1.0); -} else if (healpixColor.uDimensions == 4) { - healpixOut = healpixVals; -} else { - healpixOut = vec4(0.0); -} -color = vec4(healpixOut.rgb, healpixOut.a * layer.opacity); -`; - -/** - * Layer extension that computes HEALPix cell colors on the GPU. - * - * Reads per-cell float values from an RGBA32F texture and converts them - * to RGBA using a 256×1 colorMap LUT texture, driven by the `dimensions` mode. - * Replaces `HealpixColorFramesExtension`. - */ -class HealpixColorExtension extends LayerExtension { - static extensionName = 'HealpixColorExtension'; - - getShaders(): unknown { - return { - modules: [healpixColorShaderModule], - inject: { - 'vs:#decl': VERTEX_DECLARATION_INJECT, - 'vs:DECKGL_FILTER_COLOR': VERTEX_COLOR_FILTER_INJECT - } - }; - } - - draw( - this: Layer, - _opts: { uniforms: unknown } - ): void { - const { - valuesTexture, - colorMapTexture, - uMin, - uMax, - uDimensions, - uValuesWidth - } = this.props as HealpixColorExtensionProps; - - for (const model of this.getModels()) { - model.shaderInputs.setProps({ - healpixColor: { - uMin, - uMax, - uDimensions, - uValuesWidth, - healpixValuesTexture: valuesTexture, - healpixColorMapTexture: colorMapTexture - } - }); - } - } -} - -/** Shared singleton used by all HEALPix sublayers. */ -export const HEALPIX_COLOR_EXTENSION = new HealpixColorExtension(); -``` - -- [ ] **Step 2: Run full suite — must still pass** - -```bash -npm test -``` - -Expected: `49 passed`. - -- [ ] **Step 3: Commit** - -```bash -git add src/extensions/healpix-color-extension.ts -git commit -m "feat: add HealpixColorExtension for GPU value-based color computation" -``` - ---- - -## Task 7: Rewrite `HealpixCellsLayer` - -**Files:** -- Modify: `src/layers/healpix-cells-layer.ts` - -- [ ] **Step 1: Replace the file contents** - -```typescript -/** - * HealpixCellsLayer — render arbitrary HEALPix cells by ID with GPU color computation. - * - * This composite layer is responsible for: - * - Resolving the effective frame from `frames[currentFrame]` merged with root props. - * - Splitting cell IDs into GPU-friendly 32-bit halves. - * - Building an RGBA32F values texture and an RGBA8 colorMap texture. - * - Smart change detection: each resource is only rebuilt when its inputs change. - * - Rendering a `HealpixCellsPrimitiveLayer` sublayer that computes colors on the GPU. - */ -import { - CompositeLayer, - DefaultProps, - Layer, - LayerExtension, - UpdateParameters -} from '@deck.gl/core'; -import type { Texture } from '@luma.gl/core'; -import { splitCellIds } from '../utils/cell-id-split'; -import { HealpixCellsPrimitiveLayer } from './healpix-cells-primitive-layer'; -import { HEALPIX_COLOR_EXTENSION } from '../extensions/healpix-color-extension'; -import { resolveFrame, type ResolvedFrame } from '../utils/resolve-frame'; -import { packValuesData } from '../utils/values-texture'; -import type { CellIdArray } from '../types/cell-ids'; -import type { HealpixCellsLayerProps, HealpixFrameObject } from '../types/layer-props'; - -type _HealpixCellsLayerProps = { - nside: number; - cellIds: CellIdArray; - scheme: 'nest' | 'ring'; - values: ArrayLike | null; - min: number; - max: number; - dimensions: 1 | 2 | 3 | 4; - colorMap: Uint8Array | null; - frames: HealpixFrameObject[] | null; - currentFrame: number; -}; - -type HealpixCellsLayerState = { - cellIdLo: Uint32Array; - cellIdHi: Uint32Array; - valuesTexture: Texture | null; - colorMapTexture: Texture | null; - valuesTextureWidth: number; - /** Last resolved frame — used for change detection in updateState. */ - prevResolved: ResolvedFrame | null; -}; - -const defaultProps: DefaultProps<_HealpixCellsLayerProps> = { - nside: { type: 'number', value: 0 }, - cellIds: { type: 'object', value: new Uint32Array(0), compare: true }, - // @ts-expect-error deck.gl DefaultProps has no 'string' type. - scheme: { type: 'string', value: 'nest' }, - values: { type: 'object', value: null, compare: true }, - min: { type: 'number', value: 0 }, - max: { type: 'number', value: 1 }, - dimensions: { type: 'number', value: 1 }, - colorMap: { type: 'object', value: null, compare: true }, - frames: { type: 'object', value: null, compare: true }, - currentFrame: { type: 'number', value: 0 } -}; - -export class HealpixCellsLayer extends CompositeLayer { - static layerName = 'HealpixCellsLayer'; - static defaultProps = defaultProps; - - declare state: HealpixCellsLayerState; - - initializeState(): void { - this.setState({ - cellIdLo: new Uint32Array(0), - cellIdHi: new Uint32Array(0), - valuesTexture: null, - colorMapTexture: null, - valuesTextureWidth: 1, - prevResolved: null - }); - this._rebuildAll(); - } - - shouldUpdateState({ changeFlags }: UpdateParameters): boolean { - return !!changeFlags.propsOrDataChanged; - } - - updateState({ props }: UpdateParameters): void { - let resolved: ResolvedFrame; - try { - resolved = resolveFrame(props); - } catch (e) { - this.raiseError(e as Error, 'HealpixCellsLayer frame resolution failed'); - return; - } - - const prev = this.state.prevResolved; - - const geometryChanged = - !prev || - resolved.cellIds !== prev.cellIds || - resolved.nside !== prev.nside || - resolved.scheme !== prev.scheme; - - const valuesChanged = - !prev || - resolved.values !== prev.values || - resolved.dimensions !== prev.dimensions || - resolved.cellIds.length !== prev.cellIds.length; - - const colorMapChanged = !prev || resolved.colorMap !== prev.colorMap; - - if (geometryChanged) this._splitCellIds(resolved.cellIds); - if (valuesChanged || geometryChanged) this._updateValuesTexture(resolved); - if (colorMapChanged) this._updateColorMapTexture(resolved); - - this.setState({ prevResolved: resolved }); - } - - finalizeState(): void { - this.state.valuesTexture?.destroy(); - this.state.colorMapTexture?.destroy(); - } - - renderLayers(): Layer[] { - const { - cellIdLo, - cellIdHi, - valuesTexture, - colorMapTexture, - valuesTextureWidth, - prevResolved - } = this.state; - - if (!prevResolved || !valuesTexture || !colorMapTexture) return []; - - const { nside, scheme, cellIds, min, max, dimensions } = prevResolved; - const count = cellIds.length; - if (count === 0) return []; - - return [ - new HealpixCellsPrimitiveLayer( - this.getSubLayerProps({ - id: 'cells', - nside, - scheme, - instanceCount: count, - data: { - length: count, - attributes: { - cellIdLo: { value: cellIdLo, size: 1 }, - cellIdHi: { value: cellIdHi, size: 1 } - } - }, - valuesTexture, - colorMapTexture, - uMin: min, - uMax: max, - uDimensions: dimensions, - uValuesWidth: valuesTextureWidth, - extensions: [ - ...((this.props.extensions as LayerExtension[]) || []), - HEALPIX_COLOR_EXTENSION - ] - }) - ) - ]; - } - - /** Rebuild all resources from scratch (called on first init). */ - private _rebuildAll(): void { - let resolved: ResolvedFrame; - try { - resolved = resolveFrame(this.props); - } catch (e) { - this.raiseError(e as Error, 'HealpixCellsLayer frame resolution failed'); - return; - } - this._splitCellIds(resolved.cellIds); - this._updateValuesTexture(resolved); - this._updateColorMapTexture(resolved); - this.setState({ prevResolved: resolved }); - } - - private _splitCellIds(cellIds: CellIdArray): void { - if (!cellIds?.length) { - this.setState({ - cellIdLo: new Uint32Array(0), - cellIdHi: new Uint32Array(0) - }); - return; - } - const { lo, hi } = splitCellIds(cellIds); - this.setState({ cellIdLo: lo, cellIdHi: hi }); - } - - /** - * Build and upload an RGBA32F values texture. - * - * Each texel stores the float values for one cell in channels 0–(dimensions-1). - * The texture is folded: cell i → texel (i % width, floor(i / width)). - */ - private _updateValuesTexture(resolved: ResolvedFrame): void { - const { values, dimensions, cellIds } = resolved; - const cellCount = cellIds.length; - const oldTexture = this.state.valuesTexture; - - const maxTextureSize = this.context.device.limits.maxTextureDimension2D; - const { data, width, height } = packValuesData( - values, - dimensions, - cellCount, - maxTextureSize - ); - - const texture = this.context.device.createTexture({ - id: `${this.id}-values`, - width, - height, - dimension: '2d', - format: 'rgba32float', - sampler: { - minFilter: 'nearest', - magFilter: 'nearest', - mipmapFilter: 'none', - addressModeU: 'clamp-to-edge', - addressModeV: 'clamp-to-edge' - } - }); - texture.copyImageData({ data }); - - this.setState({ valuesTexture: texture, valuesTextureWidth: width }); - oldTexture?.destroy(); - } - - /** - * Build and upload an RGBA8 256×1 colorMap texture. - * - * Index 0 maps to min, index 255 maps to max. - */ - private _updateColorMapTexture(resolved: ResolvedFrame): void { - const { colorMap } = resolved; - const oldTexture = this.state.colorMapTexture; - - const texture = this.context.device.createTexture({ - id: `${this.id}-colormap`, - width: 256, - height: 1, - dimension: '2d', - format: 'rgba8unorm', - sampler: { - minFilter: 'nearest', - magFilter: 'nearest', - mipmapFilter: 'none', - addressModeU: 'clamp-to-edge', - addressModeV: 'clamp-to-edge' - } - }); - texture.copyImageData({ data: colorMap }); - - this.setState({ colorMapTexture: texture }); - oldTexture?.destroy(); - } -} -``` - -- [ ] **Step 2: Run full suite — must still pass** - -```bash -npm test -``` - -Expected: `49 passed`. TypeScript must compile without errors (`npx tsc --noEmit`). - -- [ ] **Step 3: Commit** - -```bash -git add src/layers/healpix-cells-layer.ts -git commit -m "feat: rewrite HealpixCellsLayer with GPU color computation and smart change detection" -``` - ---- - -## Task 8: Cleanup — Exports and Delete Old Files - -**Files:** -- Modify: `src/index.ts` -- Delete: `src/extensions/healpix-color-frames-extension.ts` -- Delete: `src/extensions/healpix-color-frames-shader-module.ts` - -- [ ] **Step 1: Update `src/index.ts`** - -```typescript -export { HealpixCellsLayer } from './layers/healpix-cells-layer'; -export type { CellIdArray } from './types/cell-ids'; -export type { - HealpixCellsLayerProps, - HealpixFrameObject, - HealpixScheme -} from './types/layer-props'; -``` - -Note: `makeColorFrameFromValues` is intentionally removed (clean break API). The `color-frame.ts` utility file and its tests remain for internal use. - -- [ ] **Step 2: Delete old extension files** - -```bash -rm src/extensions/healpix-color-frames-extension.ts -rm src/extensions/healpix-color-frames-shader-module.ts -``` - -- [ ] **Step 3: Run full suite — must still pass** - -```bash -npm test -``` - -Expected: `49 passed`. - -- [ ] **Step 4: TypeScript check** - -```bash -npx tsc --noEmit -``` - -Expected: no errors. - -- [ ] **Step 5: Commit** - -```bash -git add src/index.ts -git rm src/extensions/healpix-color-frames-extension.ts -git rm src/extensions/healpix-color-frames-shader-module.ts -git commit -m "feat: update exports and remove old color frames extension (clean break)" -``` - ---- - -## Self-Review Checklist - -- [x] **Spec coverage:** Types ✓, resolveFrame ✓, DEFAULT_COLORMAP ✓, validateColorMap ✓, packValuesData ✓, shader module ✓, extension GLSL ✓, layer change detection ✓, geometry/values/colorMap textures ✓, opacity ✓, dimensions 1-4 ✓, dimensions>4 transparent+warning (handled in extension GLSL else branch) ✓, multi-frame frame switching ✓, single-frame mode ✓, smart resource rebuild ✓, clean break exports ✓ -- [x] **No placeholders** -- [x] **Type consistency:** `ResolvedFrame` defined in Task 3, used in Tasks 7 and 8. `packValuesData` signature matches test calls. `HEALPIX_COLOR_EXTENSION` matches import in layer. diff --git a/docs/superpowers/plans/2026-04-16-gpu-xyf2loc.md b/docs/superpowers/plans/2026-04-16-gpu-xyf2loc.md deleted file mode 100644 index 140e7a8..0000000 --- a/docs/superpowers/plans/2026-04-16-gpu-xyf2loc.md +++ /dev/null @@ -1,687 +0,0 @@ -# GPU xyf2loc Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the faulty 570-line fp64 vertex shader with a clean ~80-line float32 shader porting the HEALPix C++ `xyf2loc` algorithm, with CPU-side cell ID decomposition via healpix-ts. - -**Architecture:** CPU decomposes cell IDs into `(face, ix, iy)` using healpix-ts functions, packs them into two `Uint32Array` instance attributes. GPU vertex shader unpacks, selects a corner grid vertex, and runs `xyf2loc` to compute `(z, phi)` → `(lon°, lat°)` → deck.gl projection. The shader uses float32 throughout with no fp64 emulation. - -**Tech Stack:** TypeScript, GLSL ES 3.00, deck.gl 9.x, luma.gl 9.x, healpix-ts, Jest - ---- - -## File Structure - -| File | Action | Responsibility | -|------|--------|----------------| -| `src/utils/decompose-cell-ids.ts` | Create | CPU decomposition: cellIds → packed (face,ix,iy) | -| `src/utils/decompose-cell-ids.test.ts` | Create | Tests for decomposition | -| `src/shaders/healpix-corners.glsl.ts` | Rewrite | xyf2loc vertex + fragment shaders | -| `src/shaders/healpix-cells-shader-module.ts` | Modify | Remove `scheme` uniform | -| `src/layers/healpix-cells-primitive-layer.ts` | Modify | New attributes, remove scheme | -| `src/layers/healpix-cells-layer.ts` | Modify | Wire decomposeCellIds, new attribute names | -| `src/utils/cell-id-split.ts` | Delete | Replaced by decompose-cell-ids | -| `src/utils/cell-id-split.test.ts` | Delete | Replaced by decompose-cell-ids tests | - ---- - -### Task 1: CPU cell ID decomposition — tests - -**Files:** -- Create: `src/utils/decompose-cell-ids.test.ts` - -- [ ] **Step 1: Write the tests** - -```typescript -import { decomposeCellIds } from './decompose-cell-ids'; - -describe('decomposeCellIds', () => { - it('decomposes NEST cell IDs at nside=1', () => { - const cellIds = new Uint32Array([0, 1, 11]); - const { faceIx, iy } = decomposeCellIds(cellIds, 1, 'nest'); - // nside=1: each face has 1 pixel. face=cellId, ix=0, iy=0 - // faceIx = (face << 18) | ix - expect(faceIx[0]).toBe(0 << 18); // face=0, ix=0 - expect(faceIx[1]).toBe(1 << 18); // face=1, ix=0 - expect(faceIx[2]).toBe(11 << 18); // face=11, ix=0 - expect(iy[0]).toBe(0); - expect(iy[1]).toBe(0); - expect(iy[2]).toBe(0); - }); - - it('decomposes NEST cell IDs at nside=8', () => { - const cellIds = new Uint32Array([0]); - const { faceIx, iy } = decomposeCellIds(cellIds, 8, 'nest'); - // cell 0 at nside=8: face=0, morton decode of 0 → ix=0, iy=0 - expect(faceIx[0]).toBe((0 << 18) | 0); - expect(iy[0]).toBe(0); - }); - - it('decomposes RING cell IDs at nside=1', () => { - // At nside=1, ring scheme: 12 pixels total, ring ordering - const cellIds = new Uint32Array([0, 4, 8]); - const { faceIx, iy } = decomposeCellIds(cellIds, 1, 'ring'); - // ring2fxy(1, 0) → face=0, x=0, y=0 - expect(faceIx[0]).toBe(0 << 18); - expect(iy[0]).toBe(0); - }); - - it('decomposes large NEST cell IDs (Float64Array) at nside=262144', () => { - // face 1, ix=0, iy=0 → cellId = 1 * 262144^2 = 68719476736 - const cellIds = new Float64Array([68719476736]); - const { faceIx, iy } = decomposeCellIds(cellIds, 262144, 'nest'); - expect(faceIx[0]).toBe((1 << 18) | 0); - expect(iy[0]).toBe(0); - }); - - it('returns empty arrays for empty input', () => { - const cellIds = new Uint32Array(0); - const { faceIx, iy } = decomposeCellIds(cellIds, 8, 'nest'); - expect(faceIx.length).toBe(0); - expect(iy.length).toBe(0); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `npx jest src/utils/decompose-cell-ids.test.ts --no-cache` -Expected: FAIL — `Cannot find module './decompose-cell-ids'` - -- [ ] **Step 3: Commit** - -```bash -git add src/utils/decompose-cell-ids.test.ts -git commit -m "test: add decomposeCellIds tests" -``` - ---- - -### Task 2: CPU cell ID decomposition — implementation - -**Files:** -- Create: `src/utils/decompose-cell-ids.ts` - -- [ ] **Step 1: Implement decomposeCellIds** - -```typescript -import { nest2fxy } from 'healpix-ts'; -import { ring2fxy } from 'healpix-ts'; -import type { CellIdArray } from '../types/cell-ids'; -import type { HealpixScheme } from '../types/layer-props'; - -export type DecomposedCellIds = { - faceIx: Uint32Array; - iy: Uint32Array; -}; - -export function decomposeCellIds( - cellIds: CellIdArray, - nside: number, - scheme: HealpixScheme -): DecomposedCellIds { - const n = cellIds.length; - const faceIx = new Uint32Array(n); - const iy = new Uint32Array(n); - const toFxy = scheme === 'nest' ? nest2fxy : ring2fxy; - - for (let i = 0; i < n; i++) { - const { f, x, y } = toFxy(nside, cellIds[i]); - faceIx[i] = (f << 18) | x; - iy[i] = y; - } - - return { faceIx, iy }; -} -``` - -- [ ] **Step 2: Run tests to verify they pass** - -Run: `npx jest src/utils/decompose-cell-ids.test.ts --no-cache` -Expected: All 5 tests PASS - -- [ ] **Step 3: Commit** - -```bash -git add src/utils/decompose-cell-ids.ts -git commit -m "feat: add decomposeCellIds using healpix-ts" -``` - ---- - -### Task 3: Rewrite the vertex and fragment shaders - -**Files:** -- Rewrite: `src/shaders/healpix-corners.glsl.ts` - -- [ ] **Step 1: Replace the entire file** - -```typescript -export const HEALPIX_VERTEX_SHADER: string = /* glsl */ `\ -#version 300 es -#define SHADER_NAME healpix-cells-vertex -precision highp float; -precision highp int; - -in uint faceIx; -in uint instIy; -in vec3 positions; - -out vec4 vColor; - -const int jrll[12] = int[12](2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4); -const int jpll[12] = int[12](1, 3, 5, 7, 0, 2, 4, 6, 1, 3, 5, 7); - -void main() { - int face = int(faceIx >> 18u); - int ix = int(faceIx & 0x3FFFFu); - int iy = int(instIy); - - // Corner selection: index buffer [0,1,2,0,2,3] → gl_VertexID cycles 0,1,2,0,2,3 - // 0 = North (ix+1, iy+1) - // 1 = West (ix, iy+1) - // 2 = South (ix, iy) - // 3 = East (ix+1, iy) - int ci = gl_VertexID % 4; - int cx = ix + ((ci == 0 || ci == 3) ? 1 : 0); - int cy = iy + ((ci == 0 || ci == 1) ? 1 : 0); - - float nside_f = float(healpixCells.nside); - float x_n = float(cx) / nside_f; - float y_n = float(cy) / nside_f; - - // xyf2loc: (face, x_norm, y_norm) → (z, phi) - // Ported from HEALPix C++ healpix_base.cc xyf2loc (line 1344) - float jr = float(jrll[face]) - x_n - y_n; - float nr; - float z; - - if (jr < 1.0) { - nr = jr; - float tmp = nr * nr / 3.0; - z = 1.0 - tmp; - } else if (jr > 3.0) { - nr = 4.0 - jr; - float tmp = nr * nr / 3.0; - z = tmp - 1.0; - } else { - nr = 1.0; - z = (2.0 - jr) * 2.0 / 3.0; - } - - float tmp_phi = float(jpll[face]) * nr + x_n - y_n; - if (tmp_phi < 0.0) tmp_phi += 8.0; - if (tmp_phi >= 8.0) tmp_phi -= 8.0; - float phi = (nr < 1e-15) ? 0.0 : (PI * 0.25 * tmp_phi) / nr; - - // (z, phi) → (lon°, lat°) - float lat_deg = asin(clamp(z, -1.0, 1.0)) * (180.0 / PI); - float lon_deg = phi * (180.0 / PI); - lon_deg -= 360.0 * floor((lon_deg + 180.0) / 360.0); - - vec4 pos = vec4(lon_deg, lat_deg, 0.0, 1.0); - geometry.position = pos; - gl_Position = project_common_position_to_clipspace(project_position(pos)); - - vColor = vec4(1.0); - DECKGL_FILTER_COLOR(vColor, geometry); -} -`; - -export const HEALPIX_FRAGMENT_SHADER: string = /* glsl */ `\ -#version 300 es -precision highp float; - -in vec4 vColor; -out vec4 fragColor; - -void main() { - fragColor = vColor; - DECKGL_FILTER_COLOR(fragColor, geometry); -} -`; -``` - -- [ ] **Step 2: Verify the TypeScript compiles** - -Run: `npx tsc --noEmit src/shaders/healpix-corners.glsl.ts` -Expected: No errors (file exports two string constants) - -- [ ] **Step 3: Commit** - -```bash -git add src/shaders/healpix-corners.glsl.ts -git commit -m "feat: rewrite shader with xyf2loc algorithm (~80 lines replaces 570)" -``` - ---- - -### Task 4: Update the shader module — remove scheme - -**Files:** -- Modify: `src/shaders/healpix-cells-shader-module.ts` - -- [ ] **Step 1: Replace the file contents** - -```typescript -import type { ShaderModule } from '@luma.gl/shadertools'; - -export type HealpixCellsProps = { - nside: number; -}; - -export const healpixCellsShaderModule = { - name: 'healpixCells', - vs: `\ -uniform healpixCellsUniforms { - uint nside; -} healpixCells; -`, - uniformTypes: { - nside: 'u32' - } -} as const satisfies ShaderModule; -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/shaders/healpix-cells-shader-module.ts -git commit -m "refactor: remove scheme uniform from shader module" -``` - ---- - -### Task 5: Update the primitive layer — new attributes - -**Files:** -- Modify: `src/layers/healpix-cells-primitive-layer.ts` - -- [ ] **Step 1: Replace the file contents** - -```typescript -import { - DefaultProps, - Layer, - LayerContext, - picking, - project32, - UpdateParameters -} from '@deck.gl/core'; -import { Geometry, Model } from '@luma.gl/engine'; -import type { RenderPass } from '@luma.gl/core'; -import { - HEALPIX_FRAGMENT_SHADER, - HEALPIX_VERTEX_SHADER -} from '../shaders/healpix-corners.glsl'; -import { healpixCellsShaderModule } from '../shaders/healpix-cells-shader-module'; - -export type HealpixCellsPrimitiveLayerProps = { - nside: number; - instanceCount: number; -}; - -type _HealpixCellsPrimitiveLayerProps = HealpixCellsPrimitiveLayerProps; - -type HealpixCellsPrimitiveLayerMergedProps = _HealpixCellsPrimitiveLayerProps & - import('@deck.gl/core').LayerProps; - -const defaultProps: DefaultProps<_HealpixCellsPrimitiveLayerProps> = { - nside: { type: 'number', value: 1 }, - instanceCount: { type: 'number', value: 0 } -}; - -const QUAD_INDICES = new Uint16Array([0, 1, 2, 0, 2, 3]); -const QUAD_POSITIONS = new Float32Array(12); - -export class HealpixCellsPrimitiveLayer extends Layer { - static layerName = 'HealpixCellsPrimitiveLayer'; - static defaultProps = defaultProps; - - declare state: { model: Model | null }; - - getNumInstances(): number { - return this.props.instanceCount; - } - - getShaders(): ReturnType { - return super.getShaders({ - vs: HEALPIX_VERTEX_SHADER, - fs: HEALPIX_FRAGMENT_SHADER, - modules: [project32, picking, healpixCellsShaderModule] - }); - } - - initializeState(_context: LayerContext): void { - this.getAttributeManager()!.addInstanced({ - faceIx: { size: 1, type: 'uint32', noAlloc: true }, - instIy: { size: 1, type: 'uint32', noAlloc: true } - }); - } - - updateState(params: UpdateParameters): void { - super.updateState(params); - if (params.changeFlags.extensionsChanged || !this.state.model) { - this.state.model?.destroy(); - this.state.model = this._getModel(); - this.getAttributeManager()!.invalidateAll(); - } - } - - finalizeState(context: LayerContext): void { - super.finalizeState(context); - this.state.model?.destroy(); - } - - draw({ renderPass }: { renderPass: RenderPass }): void { - const { model } = this.state; - if (!model || this.props.instanceCount === 0) return; - - model.shaderInputs.setProps({ - healpixCells: { - nside: this.props.nside - } - }); - model.setInstanceCount(this.props.instanceCount); - model.draw(renderPass); - } - - private _getModel(): Model { - const parameters = - this.context.device.type === 'webgpu' - ? { - depthWriteEnabled: true, - depthCompare: 'less-equal' as const - } - : undefined; - - return new Model(this.context.device, { - ...this.getShaders(), - id: `${this.props.id}-model`, - bufferLayout: this.getAttributeManager()!.getBufferLayouts(), - geometry: new Geometry({ - topology: 'triangle-list', - attributes: { - indices: QUAD_INDICES, - positions: { size: 3, value: QUAD_POSITIONS } - } - }), - isInstanced: true, - parameters - }); - } -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/layers/healpix-cells-primitive-layer.ts -git commit -m "refactor: primitive layer uses faceIx/instIy attributes" -``` - ---- - -### Task 6: Update the composite layer — wire decomposeCellIds - -**Files:** -- Modify: `src/layers/healpix-cells-layer.ts` - -- [ ] **Step 1: Update imports** - -Replace: -```typescript -import { splitCellIds } from '../utils/cell-id-split'; -``` - -With: -```typescript -import { decomposeCellIds } from '../utils/decompose-cell-ids'; -``` - -- [ ] **Step 2: Update state type** - -Replace: -```typescript -type HealpixCellsLayerState = { - cellIdLo: Uint32Array; - cellIdHi: Uint32Array; - frameTexture: Texture | null; - cellTextureWidth: number; - frameCount: number; -}; -``` - -With: -```typescript -type HealpixCellsLayerState = { - faceIx: Uint32Array; - iy: Uint32Array; - frameTexture: Texture | null; - cellTextureWidth: number; - frameCount: number; -}; -``` - -- [ ] **Step 3: Update initializeState** - -Replace: -```typescript - initializeState(): void { - this.setState({ - cellIdLo: new Uint32Array(0), - cellIdHi: new Uint32Array(0), - frameTexture: null, - cellTextureWidth: 1, - frameCount: 0 - }); - this._splitCellIds(); - this._updateColorTexture(); - } -``` - -With: -```typescript - initializeState(): void { - this.setState({ - faceIx: new Uint32Array(0), - iy: new Uint32Array(0), - frameTexture: null, - cellTextureWidth: 1, - frameCount: 0 - }); - this._decomposeCellIds(); - this._updateColorTexture(); - } -``` - -- [ ] **Step 4: Update updateState** - -Replace `this._splitCellIds()` with `this._decomposeCellIds()`. - -- [ ] **Step 5: Update renderLayers** - -Replace: -```typescript - const { cellIdLo, cellIdHi, frameTexture, cellTextureWidth, frameCount } = - this.state; - const { cellIds, nside, scheme, currentFrame } = this.props; -``` - -With: -```typescript - const { faceIx, iy, frameTexture, cellTextureWidth, frameCount } = - this.state; - const { cellIds, nside, currentFrame } = this.props; -``` - -Replace the sublayer construction: -```typescript - return [ - new HealpixCellsPrimitiveLayer( - this.getSubLayerProps({ - id: 'cells', - nside, - scheme, - instanceCount: count, - data: { - length: count, - attributes: { - cellIdLo: { value: cellIdLo, size: 1 }, - cellIdHi: { value: cellIdHi, size: 1 } - } - }, - frameTexture, - frameIndex, - cellTextureWidth, - extensions: [ - ...((this.props.extensions as LayerExtension[]) || []), - HEALPIX_COLOR_FRAMES_EXTENSION - ] - }) - ) - ]; -``` - -With: -```typescript - return [ - new HealpixCellsPrimitiveLayer( - this.getSubLayerProps({ - id: 'cells', - nside, - instanceCount: count, - data: { - length: count, - attributes: { - faceIx: { value: faceIx, size: 1 }, - instIy: { value: iy, size: 1 } - } - }, - frameTexture, - frameIndex, - cellTextureWidth, - extensions: [ - ...((this.props.extensions as LayerExtension[]) || []), - HEALPIX_COLOR_FRAMES_EXTENSION - ] - }) - ) - ]; -``` - -- [ ] **Step 6: Replace \_splitCellIds with \_decomposeCellIds** - -Replace: -```typescript - private _splitCellIds(): void { - const { cellIds } = this.props; - if (!cellIds?.length) { - this.setState({ - cellIdLo: new Uint32Array(0), - cellIdHi: new Uint32Array(0) - }); - return; - } - const { lo, hi } = splitCellIds(cellIds); - this.setState({ cellIdLo: lo, cellIdHi: hi }); - } -``` - -With: -```typescript - private _decomposeCellIds(): void { - const { cellIds, nside, scheme } = this.props; - if (!cellIds?.length) { - this.setState({ - faceIx: new Uint32Array(0), - iy: new Uint32Array(0) - }); - return; - } - const { faceIx, iy } = decomposeCellIds(cellIds, nside, scheme); - this.setState({ faceIx, iy }); - } -``` - -- [ ] **Step 7: Commit** - -```bash -git add src/layers/healpix-cells-layer.ts -git commit -m "refactor: composite layer uses decomposeCellIds" -``` - ---- - -### Task 7: Delete old cell-id-split files - -**Files:** -- Delete: `src/utils/cell-id-split.ts` -- Delete: `src/utils/cell-id-split.test.ts` - -- [ ] **Step 1: Delete the files** - -```bash -rm src/utils/cell-id-split.ts src/utils/cell-id-split.test.ts -``` - -- [ ] **Step 2: Run full test suite to verify nothing references deleted files** - -Run: `npx jest --no-cache` -Expected: All tests pass. No import errors for `cell-id-split`. - -- [ ] **Step 3: Commit** - -```bash -git add -A -git commit -m "chore: remove cell-id-split (replaced by decompose-cell-ids)" -``` - ---- - -### Task 8: Update exports in index.ts if needed - -**Files:** -- Check: `src/index.ts` - -- [ ] **Step 1: Verify index.ts doesn't export cell-id-split** - -Read `src/index.ts`. The current exports are: -```typescript -export { HealpixCellsLayer } from './layers/healpix-cells-layer'; -export { makeColorFrameFromValues } from './utils/color-frame'; -export type { CellIdArray } from './types/cell-ids'; -export type { HealpixCellsLayerProps, HealpixScheme } from './types/layer-props'; -``` - -No changes needed — `cell-id-split` was never publicly exported. - -- [ ] **Step 2: Commit (skip if no changes needed)** - -No commit needed. - ---- - -### Task 9: Run full test suite and verify - -- [ ] **Step 1: Run all tests** - -Run: `npx jest --no-cache` -Expected: All tests pass (decompose-cell-ids tests + existing color-frame + array-buffer + healpix-reference tests). - -- [ ] **Step 2: Run TypeScript compilation check** - -Run: `npx tsc --noEmit` -Expected: No type errors. - -- [ ] **Step 3: Run linter** - -Run: `npx eslint src/` -Expected: No errors (warnings OK). - -- [ ] **Step 4: Final commit if any linter fixes were needed** - -```bash -git add -A -git commit -m "fix: address linter issues" -``` diff --git a/docs/superpowers/plans/2026-04-21-healpix-gpu-decode.md b/docs/superpowers/plans/2026-04-21-healpix-gpu-decode.md deleted file mode 100644 index d44f09f..0000000 --- a/docs/superpowers/plans/2026-04-21-healpix-gpu-decode.md +++ /dev/null @@ -1,2513 +0,0 @@ -# HEALPix GPU NEST/RING decode Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Move `nest2fxy` and `ring2fxy` from CPU to GPU; reorganize the monolithic 264-line corner shader into focused GLSL modules. - -**Architecture:** A tiny CPU ID-split replaces `decomposeCellIds`. The vertex shader receives `uvec2` cell IDs and a `scheme` uniform, runs the decoder on-GPU, then feeds `(face, ix, iy)` into the existing `fxyCorner` corner-expansion math. Shader is assembled from six focused files (`int64`, `fp64`, `healpix-decompose`, `healpix-corners`, `healpix-cells.vs`, `healpix-cells.fs`) via string concatenation in `shaders/index.ts`. A Jest-only JS reference mirrors the GLSL integer ops and is tested against `healpix-ts` before the shader is touched; GPU transform-feedback pages under `test/gpu/` catch regressions in the live pipeline. - -**Tech Stack:** TypeScript, Jest, deck.gl, luma.gl, WebGL2, `healpix-ts` (CPU reference only). Tests: Jest unit + browser transform-feedback HTML pages. - ---- - -## Spec reference - -See `docs/superpowers/specs/2026-04-21-healpix-gpu-decode-design.md`. Section numbers cited below refer to that file. - -## File map - -**Created:** -- `src/shaders/__tests__/gpu-decode-reference.ts` — JS port of GLSL integer ops + decoders (test-only utility) -- `src/shaders/__tests__/gpu-decode-reference.test.ts` — tests it against `healpix-ts` -- `src/shaders/int64.glsl.ts`, `src/shaders/fp64.glsl.ts`, `src/shaders/healpix-decompose.glsl.ts` -- `src/shaders/healpix-cells.vs.glsl.ts`, `src/shaders/healpix-cells.fs.glsl.ts`, `src/shaders/index.ts` -- `src/utils/split-cell-ids.ts`, `src/utils/split-cell-ids.test.ts` -- `test/gpu/gpu-readback-ring.html`, `test/gpu/README.md` - -**Modified:** -- `src/shaders/healpix-corners.glsl.ts` — slimmed to `fxyCorner(...)` function only -- `src/shaders/healpix-cells-shader-module.ts` — new uniforms + `getUniforms` -- `src/layers/healpix-cells-layer.ts` — swaps decompose for split, forwards new uniforms -- `src/layers/healpix-cells-primitive-layer.ts` — attribute rename + shader import path - -**Deleted:** -- `src/utils/decompose-cell-ids.ts` -- `src/utils/decompose-cell-ids.test.ts` - -**Moved:** -- `tmp/gpu-readback.html` → `test/gpu/gpu-readback-nest-equatorial.html` -- `tmp/gpu-readback-polar.html` → `test/gpu/gpu-readback-nest-polar.html` -- `tmp/inspect-cell.mjs` → `test/gpu/inspect-cell.mjs` -- `tmp/compute-truth.mjs` → `test/gpu/compute-truth.mjs` - ---- - -## Task 1: JS `u64_*` helpers + tests (Jest) - -**Files:** -- Create: `src/shaders/__tests__/gpu-decode-reference.ts` -- Create: `src/shaders/__tests__/gpu-decode-reference.test.ts` - -These helpers mirror the GLSL `int64.glsl.ts` functions byte-for-byte. They represent a `uvec2` as a tuple `[lo, hi]` of JS numbers (both `>>> 0`-canonicalised u32s). All multi-word ops follow the exact structure the GLSL version will have, so the shader is a mechanical transcription. - -- [ ] **Step 1: Write the failing test file** - -```ts -// src/shaders/__tests__/gpu-decode-reference.test.ts -import { - u64_add, - u64_sub, - u64_mul32, - u64_shr, - u64_shl, - u64_and, - u64_lt, - u64_div32, - u64_isqrt, - toBig, - fromBig -} from './gpu-decode-reference'; - -describe('u64_* helpers', () => { - describe('roundtrip', () => { - it('toBig / fromBig roundtrip on boundaries', () => { - const cases = [0n, 1n, TWO32 - 1n, TWO32, TWO32 + 1n, (1n << 52n) - 1n]; - for (const x of cases) { - expect(toBig(fromBig(x))).toBe(x); - } - }); - }); - - describe('u64_add', () => { - it('no carry', () => { - expect(u64_add([1, 0], [2, 0])).toEqual([3, 0]); - }); - it('carry into hi', () => { - expect(u64_add([0xffffffff, 0], [1, 0])).toEqual([0, 1]); - }); - it('full 64-bit add, no overflow', () => { - const a = fromBig(0xdeadbeefcafef00dn); - const b = fromBig(0x0000000112345678n); - expect(toBig(u64_add(a, b))).toBe(0xdeadbef0df237685n); - }); - }); - - describe('u64_sub', () => { - it('no borrow', () => { - expect(u64_sub([5, 0], [2, 0])).toEqual([3, 0]); - }); - it('borrow from hi', () => { - expect(u64_sub([0, 1], [1, 0])).toEqual([0xffffffff, 0]); - }); - it('full 64-bit sub', () => { - const a = fromBig(0x100000000n); - const b = fromBig(1n); - expect(toBig(u64_sub(a, b))).toBe(0xffffffffn); - }); - }); - - describe('u64_mul32', () => { - it('small * small', () => { - expect(u64_mul32(3, 7)).toEqual([21, 0]); - }); - it('big * big overflows into hi', () => { - expect(toBig(u64_mul32(0xffffffff, 0xffffffff))).toBe( - 0xfffffffe00000001n - ); - }); - it('2^16 * 2^16 = 2^32', () => { - expect(toBig(u64_mul32(0x10000, 0x10000))).toBe(0x100000000n); - }); - }); - - describe('u64_shr', () => { - it('shift 0 is identity', () => { - expect(u64_shr([0xdeadbeef, 0xcafef00d], 0)).toEqual([ - 0xdeadbeef, 0xcafef00d - ]); - }); - it('shift 4 within lo', () => { - expect(toBig(u64_shr(fromBig(0x123456789abcdef0n), 4))).toBe( - 0x0123456789abcdefn - ); - }); - it('shift 32 moves hi to lo', () => { - expect(u64_shr([0xdeadbeef, 0xcafef00d], 32)).toEqual([0xcafef00d, 0]); - }); - it('shift 48 ', () => { - expect(toBig(u64_shr(fromBig(0x0001234567890000n), 48))).toBe(0x123n); - }); - it('shift 63', () => { - expect(u64_shr([0, 0x80000000], 63)).toEqual([1, 0]); - }); - }); - - describe('u64_shl', () => { - it('shift 32 moves lo to hi', () => { - expect(u64_shl([0xdeadbeef, 0], 32)).toEqual([0, 0xdeadbeef]); - }); - it('shift 1 with cross-half carry', () => { - expect(u64_shl([0x80000000, 0], 1)).toEqual([0, 1]); - }); - it('shift 48', () => { - expect(toBig(u64_shl(fromBig(0x123n), 48))).toBe(0x0123000000000000n); - }); - }); - - describe('u64_and', () => { - it('mask lower 24 bits', () => { - const v = fromBig(0xdeadbeefcafef00dn); - const mask = fromBig(0xffffffn); - expect(toBig(u64_and(v, mask))).toBe(0xfef00dn); - }); - }); - - describe('u64_lt', () => { - it('hi dominates', () => { - expect(u64_lt([100, 1], [1, 2])).toBe(true); - }); - it('tie on hi, lo decides', () => { - expect(u64_lt([1, 5], [2, 5])).toBe(true); - expect(u64_lt([5, 5], [2, 5])).toBe(false); - }); - it('equal → false', () => { - expect(u64_lt([5, 5], [5, 5])).toBe(false); - }); - }); - - describe('u64_div32', () => { - it('exact division', () => { - const out = u64_div32(fromBig(1000n), 10); - expect(out.q).toBe(100); - expect(out.r).toBe(0); - }); - it('with remainder', () => { - const out = u64_div32(fromBig(1003n), 10); - expect(out.q).toBe(100); - expect(out.r).toBe(3); - }); - it('large dividend', () => { - const n = (1n << 50n) + 12345n; - const d = 4_000_003; - const out = u64_div32(fromBig(n), d); - const q = n / BigInt(d); - const r = n - q * BigInt(d); - expect(BigInt(out.q)).toBe(q); - expect(BigInt(out.r)).toBe(r); - }); - it('k=2^51, d=2^26 (equatorial worst case)', () => { - const n = 1n << 51n; - const d = 1 << 26; - const out = u64_div32(fromBig(n), d); - expect(BigInt(out.q)).toBe(n / BigInt(d)); - expect(out.r).toBe(0); - }); - }); - - describe('u64_isqrt', () => { - it('square numbers', () => { - expect(u64_isqrt(fromBig(0n))).toBe(0); - expect(u64_isqrt(fromBig(1n))).toBe(1); - expect(u64_isqrt(fromBig(4n))).toBe(2); - expect(u64_isqrt(fromBig(9n))).toBe(3); - expect(u64_isqrt(fromBig(100000000n))).toBe(10000); - }); - it('non-square rounds down', () => { - expect(u64_isqrt(fromBig(2n))).toBe(1); - expect(u64_isqrt(fromBig(3n))).toBe(1); - expect(u64_isqrt(fromBig(8n))).toBe(2); - expect(u64_isqrt(fromBig(99n))).toBe(9); - }); - it('large value near 2^49 (polar cap worst case)', () => { - const n = (1n << 49n) - 7n; - const expected = 23726566n; // floor(sqrt(2^49 - 7)) - expect(BigInt(u64_isqrt(fromBig(n)))).toBe(expected); - }); - it('2^52 - 1 (upper end of safe-integer domain)', () => { - const n = (1n << 52n) - 1n; - const q = BigInt(u64_isqrt(fromBig(n))); - expect(q * q <= n).toBe(true); - expect((q + 1n) * (q + 1n) > n).toBe(true); - }); - }); -}); -``` - -- [ ] **Step 2: Run the test and confirm failure** - -Run: `npm test -- gpu-decode-reference` -Expected: FAIL — the reference module doesn't exist yet. - -- [ ] **Step 3: Implement the JS reference helpers** - -```ts -// src/shaders/__tests__/gpu-decode-reference.ts -/** - * Pure-JS mirror of src/shaders/int64.glsl.ts + src/shaders/healpix-decompose.glsl.ts. - * - * Every function is structured to match its GLSL counterpart line-for-line, - * so the shader is a mechanical transcription. Uvec2 is a [lo, hi] tuple of - * u32-canonical JS numbers. Hot path avoids BigInt to keep Jest fast. - */ - -export type U64 = readonly [number, number]; - -const TWO32 = 4294967296; - -export function fromBig(x: bigint): U64 { - return [Number(x & 0xffffffffn), Number((x >> 32n) & 0xffffffffn)]; -} - -export function toBig(v: U64): bigint { - return (BigInt(v[1]) << 32n) | BigInt(v[0]); -} - -export function u64_add(a: U64, b: U64): U64 { - const loSum = a[0] + b[0]; - const lo = loSum >>> 0; - const carry = loSum >= TWO32 ? 1 : 0; - const hi = (a[1] + b[1] + carry) >>> 0; - return [lo, hi]; -} - -export function u64_sub(a: U64, b: U64): U64 { - const lo = (a[0] - b[0]) >>> 0; - const borrow = a[0] < b[0] ? 1 : 0; - const hi = (a[1] - b[1] - borrow) >>> 0; - return [lo, hi]; -} - -export function u64_mul32(a: number, b: number): U64 { - const aLo = a & 0xffff; - const aHi = (a >>> 16) & 0xffff; - const bLo = b & 0xffff; - const bHi = (b >>> 16) & 0xffff; - const p0 = aLo * bLo; - const p1 = aLo * bHi; - const p2 = aHi * bLo; - const p3 = aHi * bHi; - const mid = p1 + p2; - const midCarry = mid > 0xffffffff ? 0x10000 : 0; - const loFull = p0 + ((mid & 0xffff) << 16); - const lo = loFull >>> 0; - const loCarry = loFull >= TWO32 ? 1 : 0; - const hi = (p3 + ((mid >>> 16) & 0xffff) + midCarry + loCarry) >>> 0; - return [lo, hi]; -} - -export function u64_shr(v: U64, s: number): U64 { - if (s === 0) return v; - if (s >= 32) { - const s2 = s - 32; - const lo = s2 === 0 ? v[1] : (v[1] >>> s2); - return [lo >>> 0, 0]; - } - const lo = ((v[0] >>> s) | (v[1] << (32 - s))) >>> 0; - const hi = (v[1] >>> s) >>> 0; - return [lo, hi]; -} - -export function u64_shl(v: U64, s: number): U64 { - if (s === 0) return v; - if (s >= 32) { - const s2 = s - 32; - const hi = s2 === 0 ? v[0] : (v[0] << s2); - return [0, hi >>> 0]; - } - const lo = (v[0] << s) >>> 0; - const hi = ((v[1] << s) | (v[0] >>> (32 - s))) >>> 0; - return [lo, hi]; -} - -export function u64_and(a: U64, b: U64): U64 { - return [(a[0] & b[0]) >>> 0, (a[1] & b[1]) >>> 0]; -} - -export function u64_lt(a: U64, b: U64): boolean { - if (a[1] !== b[1]) return a[1] < b[1]; - return a[0] < b[0]; -} - -/** - * 64 / 32 -> (quotient, remainder). fp32 seed + integer correction loop. - * Quotient must fit in u32; caller guarantees a < d * 2^32. - */ -export function u64_div32(a: U64, d: number): { q: number; r: number } { - const fa = a[1] * TWO32 + a[0]; - let q = Math.floor(fa / d) >>> 0; - let qd = u64_mul32(q, d); - while (u64_lt(a, qd)) { - q = (q - 1) >>> 0; - qd = u64_sub(qd, [d, 0]); - } - let dp1 = u64_add(qd, [d, 0]); - while (!u64_lt(a, dp1)) { - q = (q + 1) >>> 0; - qd = dp1; - dp1 = u64_add(qd, [d, 0]); - } - const rFull = u64_sub(a, qd); - return { q, r: rFull[0] >>> 0 }; -} - -/** floor(sqrt(a)) for a < 2^52. fp32-ish seed + integer correction loop. */ -export function u64_isqrt(a: U64): number { - if (a[0] === 0 && a[1] === 0) return 0; - const fa = a[1] * TWO32 + a[0]; - let i = Math.floor(Math.sqrt(fa)) >>> 0; - let ii = u64_mul32(i, i); - while (u64_lt(a, ii)) { - i = (i - 1) >>> 0; - ii = u64_mul32(i, i); - } - let next = u64_mul32(i + 1, i + 1); - while (!u64_lt(a, next)) { - i = (i + 1) >>> 0; - ii = next; - next = u64_mul32(i + 1, i + 1); - } - return i; -} -``` - -- [ ] **Step 4: Run test to verify pass** - -Run: `npm test -- gpu-decode-reference` -Expected: PASS, all `u64_*` describe blocks green. - -- [ ] **Step 5: Commit** - -```bash -git add src/shaders/__tests__/gpu-decode-reference.ts \ - src/shaders/__tests__/gpu-decode-reference.test.ts -git commit -m "test: add JS uvec2 helpers mirroring future GLSL int64.glsl.ts" -``` - ---- - -## Task 2: JS `decodeNest` reference + tests - -**Files:** -- Modify: `src/shaders/__tests__/gpu-decode-reference.ts` -- Modify: `src/shaders/__tests__/gpu-decode-reference.test.ts` - -- [ ] **Step 1: Write the failing test** - -Append to `src/shaders/__tests__/gpu-decode-reference.test.ts`: - -```ts -import { nest2fxy } from 'healpix-ts'; -import { decodeNest, compact1By1, fromBig } from './gpu-decode-reference'; - -describe('compact1By1', () => { - it('extracts even bits from a 32-bit word', () => { - expect(compact1By1(0b10101010)).toBe(0b1111); - expect(compact1By1(0x55555555)).toBe(0xffff); - expect(compact1By1(0xaaaaaaaa)).toBe(0); - }); -}); - -describe('decodeNest', () => { - const NSIDES = [1, 2, 4, 8, 256, 1 << 12, 1 << 15, 1 << 16, 1 << 20, 1 << 24]; - - for (const nside of NSIDES) { - it(`matches nest2fxy across 12 faces at nside=${nside}`, () => { - const log2n = Math.log2(nside); - const nside2 = BigInt(nside) * BigInt(nside); - for (let face = 0; face < 12; face++) { - const ids = [ - 0n, - nside2 - 1n, - nside2 / 2n, - (nside2 * 3n) / 7n - ].map((k) => BigInt(face) * nside2 + k); - for (const id of ids) { - const truth = nest2fxy(nside, Number(id)); - const cellId = fromBig(id); - const got = decodeNest(cellId, log2n); - expect({ f: got.face, x: got.ix, y: got.iy }).toEqual(truth); - } - } - }); - } - - it('random NEST ids at nside=2^24', () => { - const nside = 1 << 24; - const log2n = 24; - const nside2 = BigInt(nside) * BigInt(nside); - let state = 0x9e3779b97f4a7c15n; - const rand = () => { - state = (state * 6364136223846793005n + 1442695040888963407n) & - 0xffffffffffffffffn; - return state; - }; - for (let trial = 0; trial < 200; trial++) { - const f = Number(rand() % 12n); - const k = rand() % nside2; - const id = BigInt(f) * nside2 + k; - const truth = nest2fxy(nside, Number(id)); - const got = decodeNest(fromBig(id), log2n); - expect({ f: got.face, x: got.ix, y: got.iy }).toEqual(truth); - } - }); -}); -``` - -- [ ] **Step 2: Run test to verify failure** - -Run: `npm test -- gpu-decode-reference` -Expected: FAIL — `decodeNest` / `compact1By1` don't exist. - -- [ ] **Step 3: Implement `compact1By1` and `decodeNest`** - -Append to `src/shaders/__tests__/gpu-decode-reference.ts`: - -```ts -/** Extract even-positioned bits from a 32-bit word and pack them into bits 0..15. */ -export function compact1By1(w: number): number { - w = w & 0x55555555; - w = (w | (w >>> 1)) & 0x33333333; - w = (w | (w >>> 2)) & 0x0f0f0f0f; - w = (w | (w >>> 4)) & 0x00ff00ff; - w = (w | (w >>> 8)) & 0x0000ffff; - return w >>> 0; -} - -export type DecodeResult = { face: number; ix: number; iy: number }; - -export function decodeNest(cellId: U64, log2n: number): DecodeResult { - const k = 2 * log2n; - const one: U64 = [1, 0]; - const mask = u64_sub(u64_shl(one, k), one); - const nestInFace = u64_and(cellId, mask); - const face = u64_shr(cellId, k)[0]; - const ix = - (compact1By1(nestInFace[0]) | (compact1By1(nestInFace[1]) << 16)) >>> 0; - const iy = - (compact1By1(nestInFace[0] >>> 1) | - (compact1By1(nestInFace[1] >>> 1) << 16)) >>> - 0; - return { face, ix, iy }; -} -``` - -- [ ] **Step 4: Run test to verify pass** - -Run: `npm test -- gpu-decode-reference` -Expected: PASS, all NEST tests green across nside ∈ [1, 2^24]. - -- [ ] **Step 5: Commit** - -```bash -git add src/shaders/__tests__/gpu-decode-reference.ts \ - src/shaders/__tests__/gpu-decode-reference.test.ts -git commit -m "test: add JS decodeNest reference matching healpix-ts nest2fxy" -``` - ---- - -## Task 3: JS `decodeRing` reference + tests - -**Files:** -- Modify: `src/shaders/__tests__/gpu-decode-reference.ts` -- Modify: `src/shaders/__tests__/gpu-decode-reference.test.ts` - -- [ ] **Step 1: Write the failing test** - -Append to `src/shaders/__tests__/gpu-decode-reference.test.ts`: - -```ts -import { ring2fxy } from 'healpix-ts'; -import { decodeRing, ringUniforms, fromBig } from './gpu-decode-reference'; - -describe('decodeRing', () => { - const NSIDES_SMALL = [1, 2, 4, 8, 256, 1 << 12]; - const NSIDES_LARGE = [1 << 15, 1 << 16, 1 << 20, 1 << 24]; - - function checkRange(nside: number, ids: bigint[]): void { - const u = ringUniforms(nside); - for (const id of ids) { - const truth = ring2fxy(nside, Number(id)); - const got = decodeRing(fromBig(id), nside, u.polarLim, u.eqLim, u.npix); - expect({ f: got.face, x: got.ix, y: got.iy }).toEqual(truth); - } - } - - for (const nside of NSIDES_SMALL) { - it(`matches ring2fxy exhaustively at nside=${nside}`, () => { - const npix = 12 * nside * nside; - const ids: bigint[] = []; - for (let i = 0; i < npix; i++) ids.push(BigInt(i)); - checkRange(nside, ids); - }); - } - - for (const nside of NSIDES_LARGE) { - it(`matches ring2fxy at boundaries, nside=${nside}`, () => { - const n = BigInt(nside); - const polar = 2n * n * (n - 1n); - const eq = polar + 8n * n * n; - const npix = 12n * n * n; - const ids = [ - 0n, - 1n, - polar - 1n, - polar, - polar + 1n, - polar + 4n * n, - eq - 1n, - eq, - eq + 1n, - npix - 1n - ]; - checkRange(nside, ids); - }); - - it(`matches ring2fxy on 200 random ids, nside=${nside}`, () => { - const n = BigInt(nside); - const npix = 12n * n * n; - let state = (BigInt(nside) * 0x9e3779b97f4a7c15n) & 0xffffffffffffffffn; - const rand = () => { - state = (state * 6364136223846793005n + 1442695040888963407n) & - 0xffffffffffffffffn; - return state; - }; - const ids: bigint[] = []; - for (let i = 0; i < 200; i++) ids.push(rand() % npix); - checkRange(nside, ids); - }); - } -}); -``` - -- [ ] **Step 2: Run test to verify failure** - -Run: `npm test -- gpu-decode-reference` -Expected: FAIL — `decodeRing` / `ringUniforms` don't exist. - -- [ ] **Step 3: Implement `ringUniforms` and `decodeRing`** - -Append to `src/shaders/__tests__/gpu-decode-reference.ts`: - -```ts -export function ringUniforms(nside: number): { - polarLim: U64; - eqLim: U64; - npix: U64; -} { - const n = BigInt(nside); - const polar = 2n * n * (n - 1n); - const eq = polar + 8n * n * n; - const npix = 12n * n * n; - return { - polarLim: fromBig(polar), - eqLim: fromBig(eq), - npix: fromBig(npix) - }; -} - -export function decodeRing( - cellId: U64, - nside: number, - polarLim: U64, - eqLim: U64, - npix: U64 -): DecodeResult { - if (u64_lt(cellId, polarLim)) { - return decodeRingNorth(cellId, nside); - } - if (u64_lt(cellId, eqLim)) { - return decodeRingEquatorial(cellId, nside, polarLim); - } - return decodeRingSouth(cellId, nside, npix); -} - -function decodeRingNorth(cellId: U64, nside: number): DecodeResult { - // i = (isqrt(1 + 2p) + 1) / 2 - const onePlus2p = u64_add(u64_shl(cellId, 1), [1, 0]); - const root = u64_isqrt(onePlus2p); - const i = ((root + 1) >>> 1) >>> 0; - // j = p - 2*i*(i-1) (< 4i, fits u32) - const i2 = u64_mul32(2, i * (i - 1) >>> 0); - const jFull = u64_sub(cellId, i2); - const j = jFull[0] >>> 0; - const f = Math.floor(j / i); - const k = j - f * i; - const ix = (nside - i + k) >>> 0; - const iy = (nside - 1 - k) >>> 0; - return { face: f, ix, iy }; -} - -function decodeRingEquatorial( - cellId: U64, - nside: number, - polarLim: U64 -): DecodeResult { - const kFull = u64_sub(cellId, polarLim); - const ring = (4 * nside) >>> 0; - const { q, r: kmod } = u64_div32(kFull, ring); - const i = (nside - q) >>> 0; - const s = 1 - (i & 1); - const j = (2 * kmod + s) >>> 0; - // All u32 from here, mirrors healpix-ts ring2fxy equatorial branch - const jj = j - 4 * nside; - const ii = i + 5 * nside - 1; - const pp = (ii + jj) >> 1; - const qq = (ii - jj) >> 1; - const PP = Math.floor(pp / nside); - const QQ = Math.floor(qq / nside); - const V = 5 - (PP + QQ); - const H = PP - QQ + 4; - const face = (4 * V + ((H >> 1) & 3)) >>> 0; - const ix = (pp - PP * nside) >>> 0; - const iy = (qq - QQ * nside) >>> 0; - return { face, ix, iy }; -} - -function decodeRingSouth(cellId: U64, nside: number, npix: U64): DecodeResult { - // p = npix - cellId - 1 - const p = u64_sub(u64_sub(npix, cellId), [1, 0]); - const onePlus2p = u64_add(u64_shl(p, 1), [1, 0]); - const root = u64_isqrt(onePlus2p); - const i = ((root + 1) >>> 1) >>> 0; - const i2 = u64_mul32(2, i * (i - 1) >>> 0); - const jFull = u64_sub(p, i2); - const j = jFull[0] >>> 0; - const f = (11 - Math.floor(j / i)) >>> 0; - const k = j - Math.floor(j / i) * i; - const ix = (i - k - 1) >>> 0; - const iy = k >>> 0; - return { face: f, ix, iy }; -} -``` - -- [ ] **Step 4: Run test to verify pass** - -Run: `npm test -- gpu-decode-reference` -Expected: PASS. All RING tests green including exhaustive sweep at nside ≤ 2^12 (3,145,728 ids at nside=2^12) and boundary + random sweeps at nside up to 2^24. - -- [ ] **Step 5: Commit** - -```bash -git add src/shaders/__tests__/gpu-decode-reference.ts \ - src/shaders/__tests__/gpu-decode-reference.test.ts -git commit -m "test: add JS decodeRing reference matching healpix-ts ring2fxy" -``` - ---- - -## Task 4: Extract `fp64.glsl.ts` from the corner shader - -**Files:** -- Create: `src/shaders/fp64.glsl.ts` -- Modify: `src/shaders/healpix-corners.glsl.ts:1-94` (remove fp64 primitives) - -This is a pure refactor — no behavior change. The existing `tmp/gpu-readback.html` and `tmp/gpu-readback-polar.html` continue to pass bit-for-bit. - -- [ ] **Step 1: Create `src/shaders/fp64.glsl.ts`** - -```ts -// src/shaders/fp64.glsl.ts -/** - * Dekker / double-single arithmetic for GLSL. - * - * Each "fp64" value is a vec2 (hi, lo) with hi = rounded fp32, lo = residual. - * - * CRITICAL: Dekker primitives depend on strict fp32 round-to-nearest semantics. - * The GLSL optimizer can (and WILL) algebraically simplify these to return - * lo = 0. Round-tripping load-bearing intermediates through - * uintBitsToFloat ∘ floatBitsToUint gives the compiler an opaque identity - * that breaks algebraic simplification chains. - */ -export const FP64_GLSL: string = /* glsl */ ` -float _seal(float x) { return uintBitsToFloat(floatBitsToUint(x)); } - -vec2 _split(float a) { - const float SPLIT = 4097.0; - float t = _seal(a * SPLIT); - float hi = t - (t - a); - float lo = a - hi; - return vec2(hi, lo); -} - -vec2 _twoSum(float a, float b) { - float s = _seal(a + b); - float bb = _seal(s - a); - float err = (a - (s - bb)) + (b - bb); - return vec2(s, err); -} - -vec2 _qts(float a, float b) { - float s = _seal(a + b); - float err = b - (s - a); - return vec2(s, err); -} - -vec2 _twoProd(float a, float b) { - float p = _seal(a * b); - vec2 ap = _split(a); - vec2 bp = _split(b); - float err = ((ap.x * bp.x - p) + ap.x * bp.y + ap.y * bp.x) + ap.y * bp.y; - return vec2(p, err); -} - -vec2 _add64(vec2 a, vec2 b) { - vec2 s = _twoSum(a.x, b.x); - vec2 t = _twoSum(a.y, b.y); - s.y += t.x; - s = _qts(s.x, s.y); - s.y += t.y; - return _qts(s.x, s.y); -} - -vec2 _sub64(vec2 a, vec2 b) { return _add64(a, vec2(-b.x, -b.y)); } - -vec2 _mul64(vec2 a, vec2 b) { - vec2 p = _twoProd(a.x, b.x); - p.y += a.x * b.y + a.y * b.x; - return _qts(p.x, p.y); -} - -vec2 _div64(vec2 a, vec2 b) { - float xn = 1.0 / b.x; - vec2 yn = a * xn; - float diff = _sub64(a, _mul64(b, yn)).x; - vec2 corr = _twoProd(xn, diff); - return _add64(yn, corr); -} - -vec2 _mul64f(vec2 a, float b) { return _mul64(a, vec2(b, 0.0)); } - -const vec2 PI64 = vec2( 3.1415927, -8.742278e-8 ); -const vec2 PI2_64 = vec2( 1.5707964, -4.371139e-8 ); -const vec2 PI4_64 = vec2( 0.78539819, -2.1855695e-8); -`; -``` - -- [ ] **Step 2: Modify `src/shaders/healpix-corners.glsl.ts` — delete fp64 primitives, import `FP64_GLSL`** - -Replace lines 1–94 of the existing file with: - -```ts -import { FP64_GLSL } from './fp64.glsl'; - -export const HEALPIX_VERTEX_SHADER: string = /* glsl */ `\ -#version 300 es -#define SHADER_NAME healpix-cells-vertex -precision highp float; -precision highp int; - -in uint faceIx; -in uint instIy; -in vec3 positions; - -out vec4 vColor; - -${FP64_GLSL} - -// --------------------------------------------------------------------------- -void main() { -` + /* existing main() body starting from `int face = int(faceIx >> 24u);` */ ` -`; -``` - -Concretely: keep the existing `main()` body verbatim from line 96 onward. The only change is that the fp64 primitives (old lines 27–94) are replaced by the `${FP64_GLSL}` interpolation, and the leading `\` on the template is removed so the interpolation works. - -Full replacement file: - -```ts -import { FP64_GLSL } from './fp64.glsl'; - -export const HEALPIX_VERTEX_SHADER: string = /* glsl */ ` -#version 300 es -#define SHADER_NAME healpix-cells-vertex -precision highp float; -precision highp int; - -in uint faceIx; -in uint instIy; -in vec3 positions; - -out vec4 vColor; - -${FP64_GLSL} - -// --------------------------------------------------------------------------- -void main() { - int face = int(faceIx >> 24u); - int ix = int(faceIx & 0xFFFFFFu); - int iy = int(instIy); - - int ci = gl_VertexID % 4; - int cx = ix + ((ci == 0 || ci == 3) ? 1 : 0); - int cy = iy + ((ci == 0 || ci == 1) ? 1 : 0); - - int f_row = face / 4; - int f1 = f_row + 2; - int f2 = 2 * (face - 4 * f_row) - (f_row & 1) + 1; - int nside = int(healpixCells.nside); - - int i_ring = f1 * nside - cx - cy; - int k_ring = f2 * nside + (cx - cy) + 8 * nside; - - int period = 8 * nside; - k_ring = k_ring - (k_ring / period) * period; - if (k_ring > 4 * nside) k_ring -= period; - - int k_int = k_ring / nside; - int k_rem = k_ring - k_int * nside; - int i_int = i_ring / nside; - int i_rem = i_ring - i_int * nside; - float k_frac = float(k_rem) / float(nside); - float i_frac = float(i_rem) / float(nside); - - vec2 t_fp = _mul64f(PI4_64, float(k_int)); - t_fp = _add64(t_fp, _mul64f(PI4_64, k_frac)); - - vec2 i_ang = _mul64f(PI4_64, float(i_int)); - i_ang = _add64(i_ang, _mul64f(PI4_64, i_frac)); - vec2 u_fp = _sub64(PI2_64, i_ang); - - float u_hi = u_fp.x; - float abs_u = abs(u_hi); - - vec2 lat_rad_fp, lon_rad_fp; - if (abs_u >= PI2_64.x) { - float sgn = sign(u_hi); - lat_rad_fp = vec2(sgn * PI2_64.x, sgn * PI2_64.y); - lon_rad_fp = vec2(0.0); - } else if (abs_u <= PI4_64.x) { - vec2 three_pi = _mul64f(PI64, 3.0); - vec2 k_eq = _div64(vec2(8.0, 0.0), three_pi); - vec2 z_fp = _mul64(k_eq, u_fp); - lon_rad_fp = t_fp; - - float z_hi = clamp(z_fp.x, -1.0, 1.0); - float lat_hi_0 = _seal(asin(z_hi)); - float cos_lat0 = cos(lat_hi_0); - float sin_lat0 = sin(lat_hi_0); - float r = (sin_lat0 - z_hi) - z_fp.y; - float lat_hi = _seal(lat_hi_0 - r / cos_lat0); - float sin_lat = sin(lat_hi); - float cos_lat = cos(lat_hi); - float r2 = (sin_lat - z_hi) - z_fp.y; - float lat_lo = -r2 / cos_lat; - lat_rad_fp = vec2(lat_hi, lat_lo); - } else { - float sgn = sign(u_hi); - float s = 2.0 - 4.0 * abs_u / PI64.x; - const float INV_SQRT_6 = 0.40824829046386; - float w = abs(s) * INV_SQRT_6; - float a0 = _seal(asin(w)); - float a_hi = _seal(a0 - (sin(a0) - w) / cos(a0)); - float a_lo = -(sin(a_hi) - w) / cos(a_hi); - - vec2 delta_fp = vec2(2.0 * a_hi, 2.0 * a_lo); - vec2 lat_mag_fp = _sub64(PI2_64, delta_fp); - lat_rad_fp = vec2(sgn * lat_mag_fp.x, sgn * lat_mag_fp.y); - - float t_hi = t_fp.x; - float t_t = mod(t_hi, PI2_64.x); - float a_f = t_hi - ((abs_u - PI4_64.x) / (abs_u - PI2_64.x)) - * (t_t - PI4_64.x); - lon_rad_fp = vec2(a_f, 0.0); - } - - vec2 deg_per_rad = _div64(vec2(180.0, 0.0), PI64); - vec2 lat_deg_fp = _mul64(lat_rad_fp, deg_per_rad); - vec2 lon_deg_fp = _mul64(lon_rad_fp, deg_per_rad); - - vec3 pos_hi = vec3(lon_deg_fp.x, lat_deg_fp.x, 0.0); - vec3 pos_lo = vec3(lon_deg_fp.y, lat_deg_fp.y, 0.0); - gl_Position = project_position_to_clipspace(pos_hi, pos_lo, vec3(0.0), geometry.position); - - vColor = vec4(1.0); - DECKGL_FILTER_COLOR(vColor, geometry); -} -`; - -export const HEALPIX_FRAGMENT_SHADER: string = /* glsl */ `\ -#version 300 es -precision highp float; - -in vec4 vColor; -out vec4 fragColor; - -void main() { - fragColor = vColor; - DECKGL_FILTER_COLOR(fragColor, geometry); -} -`; -``` - -- [ ] **Step 3: Verify existing Jest tests still pass** - -Run: `npm test` -Expected: PASS (no test uses the corner shader directly; this confirms the module still compiles under tsc/jest). - -- [ ] **Step 4: Verify existing readback tests still pass** - -Serve the worktree and open the equatorial + polar readback pages: - -```bash -npx http-server . -p 8080 & -open http://localhost:8080/tmp/gpu-readback.html -open http://localhost:8080/tmp/gpu-readback-polar.html -``` - -Expected: all four corners on both pages continue to show ≤ 0.5 ULP (green) on `lon_hi`, `lat_hi`, `lon_lo`, `lat_lo`. The readback pages embed their own shader so they don't read our new `fp64.glsl.ts`; they're a regression check that our refactor didn't change the production shader's semantics. No pixel diff on the demo page either. - -- [ ] **Step 5: Commit** - -```bash -git add src/shaders/fp64.glsl.ts src/shaders/healpix-corners.glsl.ts -git commit -m "refactor: extract fp64 Dekker primitives to fp64.glsl.ts" -``` - ---- - -## Task 5: Wrap corner math as a reusable `fxyCorner` function - -**Files:** -- Modify: `src/shaders/healpix-corners.glsl.ts` (slim to function-only export) - -After this task, `healpix-corners.glsl.ts` exports a single `/* glsl */` string constant containing just the `fxyCorner(face, cx, cy, nside, out lon, out lat)` function. The `main()` body moves to `healpix-cells.vs.glsl.ts` in Task 7. - -- [ ] **Step 1: Replace `src/shaders/healpix-corners.glsl.ts` contents** - -```ts -// src/shaders/healpix-corners.glsl.ts -/** - * Corner-expansion math: (face, cx, cy, nside) → (lon_rad_fp, lat_rad_fp). - * - * Takes integer-lattice corner coordinates `(cx, cy)` = `(ix + {0,1}, iy + {0,1})` - * and emits the spherical lon/lat for that corner in fp64 (vec2 = (hi, lo)). - * - * Uses fxy2tu → tu2za composition. Inner branches: - * - polar cap (|u| > π/4): half-angle asin identity + Newton refinement - * - equatorial (|u| ≤ π/4): direct z = (8/3π)·u, asin + Newton refinement - * - exact pole (|u| ≥ π/2): lat = ±π/2, lon = 0 - * - * Depends on fp64.glsl.ts for the Dekker primitives and π constants. - */ -export const HEALPIX_CORNERS_GLSL: string = /* glsl */ ` -void fxyCorner( - int face, int cx, int cy, int nside, - out vec2 lon_rad_fp, out vec2 lat_rad_fp -) { - int f_row = face / 4; - int f1 = f_row + 2; - int f2 = 2 * (face - 4 * f_row) - (f_row & 1) + 1; - - int i_ring = f1 * nside - cx - cy; - int k_ring = f2 * nside + (cx - cy) + 8 * nside; - - int period = 8 * nside; - k_ring = k_ring - (k_ring / period) * period; - if (k_ring > 4 * nside) k_ring -= period; - - int k_int = k_ring / nside; - int k_rem = k_ring - k_int * nside; - int i_int = i_ring / nside; - int i_rem = i_ring - i_int * nside; - float k_frac = float(k_rem) / float(nside); - float i_frac = float(i_rem) / float(nside); - - vec2 t_fp = _mul64f(PI4_64, float(k_int)); - t_fp = _add64(t_fp, _mul64f(PI4_64, k_frac)); - - vec2 i_ang = _mul64f(PI4_64, float(i_int)); - i_ang = _add64(i_ang, _mul64f(PI4_64, i_frac)); - vec2 u_fp = _sub64(PI2_64, i_ang); - - float u_hi = u_fp.x; - float abs_u = abs(u_hi); - - if (abs_u >= PI2_64.x) { - float sgn = sign(u_hi); - lat_rad_fp = vec2(sgn * PI2_64.x, sgn * PI2_64.y); - lon_rad_fp = vec2(0.0); - } else if (abs_u <= PI4_64.x) { - vec2 three_pi = _mul64f(PI64, 3.0); - vec2 k_eq = _div64(vec2(8.0, 0.0), three_pi); - vec2 z_fp = _mul64(k_eq, u_fp); - lon_rad_fp = t_fp; - - float z_hi = clamp(z_fp.x, -1.0, 1.0); - float lat_hi_0 = _seal(asin(z_hi)); - float cos_lat0 = cos(lat_hi_0); - float sin_lat0 = sin(lat_hi_0); - float r = (sin_lat0 - z_hi) - z_fp.y; - float lat_hi = _seal(lat_hi_0 - r / cos_lat0); - float sin_lat = sin(lat_hi); - float cos_lat = cos(lat_hi); - float r2 = (sin_lat - z_hi) - z_fp.y; - float lat_lo = -r2 / cos_lat; - lat_rad_fp = vec2(lat_hi, lat_lo); - } else { - float sgn = sign(u_hi); - float s = 2.0 - 4.0 * abs_u / PI64.x; - const float INV_SQRT_6 = 0.40824829046386; - float w = abs(s) * INV_SQRT_6; - float a0 = _seal(asin(w)); - float a_hi = _seal(a0 - (sin(a0) - w) / cos(a0)); - float a_lo = -(sin(a_hi) - w) / cos(a_hi); - - vec2 delta_fp = vec2(2.0 * a_hi, 2.0 * a_lo); - vec2 lat_mag_fp = _sub64(PI2_64, delta_fp); - lat_rad_fp = vec2(sgn * lat_mag_fp.x, sgn * lat_mag_fp.y); - - float t_hi = t_fp.x; - float t_t = mod(t_hi, PI2_64.x); - float a_f = t_hi - ((abs_u - PI4_64.x) / (abs_u - PI2_64.x)) - * (t_t - PI4_64.x); - lon_rad_fp = vec2(a_f, 0.0); - } -} -`; -``` - -The file no longer exports `HEALPIX_VERTEX_SHADER` or `HEALPIX_FRAGMENT_SHADER`. Tasks 6–7 create the replacements; Task 9 wires them into the primitive layer. - -- [ ] **Step 2: Temporarily maintain the old exports during transition** - -The primitive layer still imports `HEALPIX_VERTEX_SHADER` and `HEALPIX_FRAGMENT_SHADER` from this file. To avoid breaking the build between now and Task 9, add a legacy shim at the bottom of `src/shaders/healpix-corners.glsl.ts`: - -```ts -import { FP64_GLSL } from './fp64.glsl'; - -/** @deprecated Transitional re-export; removed in Task 9. */ -export const HEALPIX_VERTEX_SHADER: string = /* glsl */ ` -#version 300 es -#define SHADER_NAME healpix-cells-vertex -precision highp float; -precision highp int; - -in uint faceIx; -in uint instIy; -in vec3 positions; -out vec4 vColor; - -${FP64_GLSL} -${HEALPIX_CORNERS_GLSL} - -void main() { - int face = int(faceIx >> 24u); - int ix = int(faceIx & 0xFFFFFFu); - int iy = int(instIy); - - int ci = gl_VertexID % 4; - int cx = ix + ((ci == 0 || ci == 3) ? 1 : 0); - int cy = iy + ((ci == 0 || ci == 1) ? 1 : 0); - - vec2 lon_rad_fp, lat_rad_fp; - fxyCorner(face, cx, cy, int(healpixCells.nside), lon_rad_fp, lat_rad_fp); - - vec2 deg_per_rad = _div64(vec2(180.0, 0.0), PI64); - vec2 lat_deg_fp = _mul64(lat_rad_fp, deg_per_rad); - vec2 lon_deg_fp = _mul64(lon_rad_fp, deg_per_rad); - vec3 pos_hi = vec3(lon_deg_fp.x, lat_deg_fp.x, 0.0); - vec3 pos_lo = vec3(lon_deg_fp.y, lat_deg_fp.y, 0.0); - gl_Position = project_position_to_clipspace(pos_hi, pos_lo, vec3(0.0), geometry.position); - - vColor = vec4(1.0); - DECKGL_FILTER_COLOR(vColor, geometry); -} -`; - -/** @deprecated Transitional re-export; removed in Task 9. */ -export const HEALPIX_FRAGMENT_SHADER: string = /* glsl */ `\ -#version 300 es -precision highp float; - -in vec4 vColor; -out vec4 fragColor; - -void main() { - fragColor = vColor; - DECKGL_FILTER_COLOR(fragColor, geometry); -} -`; -``` - -This shim calls `fxyCorner` instead of inlining the math; semantically identical to Task 4's output. - -- [ ] **Step 3: Verify existing tests still pass** - -Run: `npm test` -Expected: PASS. - -- [ ] **Step 4: Verify existing readback tests still pass** - -Open `http://localhost:8080/tmp/gpu-readback.html` and `/tmp/gpu-readback-polar.html`. -Expected: all corners ≤ 0.5 ULP (green). Visually confirm the demo page renders identically. - -- [ ] **Step 5: Commit** - -```bash -git add src/shaders/healpix-corners.glsl.ts -git commit -m "refactor: wrap corner math as fxyCorner function" -``` - ---- - -## Task 6: Create `int64.glsl.ts` and `healpix-decompose.glsl.ts` - -**Files:** -- Create: `src/shaders/int64.glsl.ts` -- Create: `src/shaders/healpix-decompose.glsl.ts` - -These files are not wired into the pipeline yet. They compile as TypeScript string constants only. Task 9 wires them in. - -- [ ] **Step 1: Create `src/shaders/int64.glsl.ts`** - -```ts -// src/shaders/int64.glsl.ts -/** - * uvec2-based unsigned 64-bit integer ops for GLSL. - * - * All ops mirror src/shaders/__tests__/gpu-decode-reference.ts line-for-line. - * `uvec2(lo, hi)` is the wire format; `u64_*` functions operate on it. - * - * Shift ops never shift by ≥ 32 within a single 32-bit half (GLSL undefined - * behavior on some drivers); cross-half movement is explicit. - */ -export const INT64_GLSL: string = /* glsl */ ` -uvec2 u64_add(uvec2 a, uvec2 b) { - uint lo = a.x + b.x; - uint carry = lo < a.x ? 1u : 0u; - uint hi = a.y + b.y + carry; - return uvec2(lo, hi); -} - -uvec2 u64_sub(uvec2 a, uvec2 b) { - uint lo = a.x - b.x; - uint borrow = a.x < b.x ? 1u : 0u; - uint hi = a.y - b.y - borrow; - return uvec2(lo, hi); -} - -uvec2 u64_mul32(uint a, uint b) { - uint aLo = a & 0xffffu; - uint aHi = a >> 16u; - uint bLo = b & 0xffffu; - uint bHi = b >> 16u; - uint p0 = aLo * bLo; - uint p1 = aLo * bHi; - uint p2 = aHi * bLo; - uint p3 = aHi * bHi; - uint mid = p1 + p2; - uint midCarry = mid < p1 ? 0x10000u : 0u; - uint lo0 = p0 + ((mid & 0xffffu) << 16u); - uint loCarry = lo0 < p0 ? 1u : 0u; - uint hi = p3 + (mid >> 16u) + midCarry + loCarry; - return uvec2(lo0, hi); -} - -uvec2 u64_shr(uvec2 v, uint s) { - if (s == 0u) return v; - if (s >= 32u) { - uint s2 = s - 32u; - uint lo = s2 == 0u ? v.y : (v.y >> s2); - return uvec2(lo, 0u); - } - uint lo = (v.x >> s) | (v.y << (32u - s)); - uint hi = v.y >> s; - return uvec2(lo, hi); -} - -uvec2 u64_shl(uvec2 v, uint s) { - if (s == 0u) return v; - if (s >= 32u) { - uint s2 = s - 32u; - uint hi = s2 == 0u ? v.x : (v.x << s2); - return uvec2(0u, hi); - } - uint lo = v.x << s; - uint hi = (v.y << s) | (v.x >> (32u - s)); - return uvec2(lo, hi); -} - -uvec2 u64_and(uvec2 a, uvec2 b) { - return uvec2(a.x & b.x, a.y & b.y); -} - -bool u64_lt(uvec2 a, uvec2 b) { - if (a.y != b.y) return a.y < b.y; - return a.x < b.x; -} - -// Returns quotient; writes remainder to 'rem'. Caller guarantees q fits in u32. -uint u64_div32(uvec2 a, uint d, out uint rem) { - float fa = float(a.y) * 4294967296.0 + float(a.x); - uint q = uint(floor(fa / float(d))); - uvec2 qd = u64_mul32(q, d); - // Correct downward - while (u64_lt(a, qd)) { - q = q - 1u; - qd = u64_sub(qd, uvec2(d, 0u)); - } - // Correct upward - uvec2 dp1 = u64_add(qd, uvec2(d, 0u)); - while (!u64_lt(a, dp1)) { - q = q + 1u; - qd = dp1; - dp1 = u64_add(qd, uvec2(d, 0u)); - } - uvec2 r64 = u64_sub(a, qd); - rem = r64.x; - return q; -} - -// floor(sqrt(a)) for a < 2^52. fp32 seed + integer correction loop. -uint u64_isqrt(uvec2 a) { - if (a.x == 0u && a.y == 0u) return 0u; - float fa = float(a.y) * 4294967296.0 + float(a.x); - uint i = uint(floor(sqrt(fa))); - uvec2 ii = u64_mul32(i, i); - while (u64_lt(a, ii)) { - i = i - 1u; - ii = u64_mul32(i, i); - } - uvec2 next = u64_mul32(i + 1u, i + 1u); - while (!u64_lt(a, next)) { - i = i + 1u; - ii = next; - next = u64_mul32(i + 1u, i + 1u); - } - return i; -} -`; -``` - -- [ ] **Step 2: Create `src/shaders/healpix-decompose.glsl.ts`** - -```ts -// src/shaders/healpix-decompose.glsl.ts -/** - * GPU decoders for NEST and RING HEALPix cell IDs. - * - * decodeNest(cellId, log2n) → uvec3(face, ix, iy) bit-exact - * decodeRing(cellId, nside, ...) → uvec3(face, ix, iy) bit-exact - * - * Mirrors src/shaders/__tests__/gpu-decode-reference.ts. Depends on - * int64.glsl.ts for uvec2 ops. - */ -export const HEALPIX_DECOMPOSE_GLSL: string = /* glsl */ ` -uint compact1By1(uint w) { - w = w & 0x55555555u; - w = (w | (w >> 1u)) & 0x33333333u; - w = (w | (w >> 2u)) & 0x0f0f0f0fu; - w = (w | (w >> 4u)) & 0x00ff00ffu; - w = (w | (w >> 8u)) & 0x0000ffffu; - return w; -} - -uvec3 decodeNest(uvec2 cellId, uint log2n) { - uint k = 2u * log2n; - uvec2 one = uvec2(1u, 0u); - uvec2 mask = u64_sub(u64_shl(one, k), one); - uvec2 nestInFace = u64_and(cellId, mask); - uint face = u64_shr(cellId, k).x; - uint ix = compact1By1(nestInFace.x) | (compact1By1(nestInFace.y) << 16u); - uint iy = compact1By1(nestInFace.x >> 1u) | - (compact1By1(nestInFace.y >> 1u) << 16u); - return uvec3(face, ix, iy); -} - -uvec3 decodeRingNorth(uvec2 cellId, uint nside) { - uvec2 onePlus2p = u64_add(u64_shl(cellId, 1u), uvec2(1u, 0u)); - uint root = u64_isqrt(onePlus2p); - uint i = (root + 1u) / 2u; - uvec2 i2 = u64_mul32(2u, i * (i - 1u)); - uvec2 jFull = u64_sub(cellId, i2); - uint j = jFull.x; - uint f = j / i; - uint k = j - f * i; - uint ix = nside - i + k; - uint iy = nside - 1u - k; - return uvec3(f, ix, iy); -} - -uvec3 decodeRingEquatorial(uvec2 cellId, uint nside, uvec2 polarLim) { - uvec2 kFull = u64_sub(cellId, polarLim); - uint ring = 4u * nside; - uint kmod; - uint q = u64_div32(kFull, ring, kmod); - uint i = nside - q; - uint s = 1u - (i & 1u); - uint j = 2u * kmod + s; - int jj = int(j) - 4 * int(nside); - int ii = int(i) + 5 * int(nside) - 1; - uint pp = uint((ii + jj) >> 1); - uint qq = uint((ii - jj) >> 1); - uint PP = pp / nside; - uint QQ = qq / nside; - uint V = 5u - (PP + QQ); - uint H = PP - QQ + 4u; - uint face = 4u * V + ((H >> 1u) & 3u); - uint ix = pp - PP * nside; - uint iy = qq - QQ * nside; - return uvec3(face, ix, iy); -} - -uvec3 decodeRingSouth(uvec2 cellId, uint nside, uvec2 npix) { - uvec2 p = u64_sub(u64_sub(npix, cellId), uvec2(1u, 0u)); - uvec2 onePlus2p = u64_add(u64_shl(p, 1u), uvec2(1u, 0u)); - uint root = u64_isqrt(onePlus2p); - uint i = (root + 1u) / 2u; - uvec2 i2 = u64_mul32(2u, i * (i - 1u)); - uvec2 jFull = u64_sub(p, i2); - uint j = jFull.x; - uint f = 11u - j / i; - uint k = j - (j / i) * i; - uint ix = i - k - 1u; - uint iy = k; - return uvec3(f, ix, iy); -} - -uvec3 decodeRing( - uvec2 cellId, uint nside, uvec2 polarLim, uvec2 eqLim, uvec2 npix -) { - if (u64_lt(cellId, polarLim)) { - return decodeRingNorth(cellId, nside); - } - if (u64_lt(cellId, eqLim)) { - return decodeRingEquatorial(cellId, nside, polarLim); - } - return decodeRingSouth(cellId, nside, npix); -} -`; -``` - -- [ ] **Step 3: Verify build and tests** - -Run: `npm test && npm run build` -Expected: PASS; rollup finishes without warnings on the new files. - -- [ ] **Step 4: Commit** - -```bash -git add src/shaders/int64.glsl.ts src/shaders/healpix-decompose.glsl.ts -git commit -m "feat: add GLSL uvec2 helpers and NEST/RING decoders (not wired yet)" -``` - ---- - -## Task 7: Create `healpix-cells.vs.glsl.ts`, `healpix-cells.fs.glsl.ts`, `shaders/index.ts` - -**Files:** -- Create: `src/shaders/healpix-cells.vs.glsl.ts` -- Create: `src/shaders/healpix-cells.fs.glsl.ts` -- Create: `src/shaders/index.ts` - -Still not wired in — the primitive layer keeps its old import until Task 9. - -- [ ] **Step 1: Create `src/shaders/healpix-cells.vs.glsl.ts`** - -```ts -// src/shaders/healpix-cells.vs.glsl.ts -/** - * Main vertex-shader glue: attributes + main(). - * - * Assembled with fp64, int64, healpix-decompose, healpix-corners by - * shaders/index.ts. The final shader string prepends those dependencies - * before this body. - */ -export const HEALPIX_CELLS_VS_MAIN: string = /* glsl */ ` -in uint cellIdLo; -in uint cellIdHi; -in vec3 positions; - -out vec4 vColor; - -void main() { - uvec2 cellId = uvec2(cellIdLo, cellIdHi); - - uvec3 fxy; - if (healpixCells.scheme == 0u) { - fxy = decodeNest(cellId, healpixCells.log2nside); - } else { - fxy = decodeRing( - cellId, - healpixCells.nside, - healpixCells.polarLim, - healpixCells.eqLim, - healpixCells.npix - ); - } - int face = int(fxy.x); - int ix = int(fxy.y); - int iy = int(fxy.z); - - int ci = gl_VertexID % 4; - int cx = ix + ((ci == 0 || ci == 3) ? 1 : 0); - int cy = iy + ((ci == 0 || ci == 1) ? 1 : 0); - - vec2 lon_rad_fp, lat_rad_fp; - fxyCorner(face, cx, cy, int(healpixCells.nside), lon_rad_fp, lat_rad_fp); - - vec2 deg_per_rad = _div64(vec2(180.0, 0.0), PI64); - vec2 lat_deg_fp = _mul64(lat_rad_fp, deg_per_rad); - vec2 lon_deg_fp = _mul64(lon_rad_fp, deg_per_rad); - - vec3 pos_hi = vec3(lon_deg_fp.x, lat_deg_fp.x, 0.0); - vec3 pos_lo = vec3(lon_deg_fp.y, lat_deg_fp.y, 0.0); - gl_Position = project_position_to_clipspace( - pos_hi, pos_lo, vec3(0.0), geometry.position - ); - - vColor = vec4(1.0); - DECKGL_FILTER_COLOR(vColor, geometry); -} -`; -``` - -- [ ] **Step 2: Create `src/shaders/healpix-cells.fs.glsl.ts`** - -```ts -// src/shaders/healpix-cells.fs.glsl.ts -/** Fragment shader — trivial; actual color work happens in DECKGL_FILTER_COLOR. */ -export const HEALPIX_CELLS_FS: string = /* glsl */ `\ -#version 300 es -precision highp float; - -in vec4 vColor; -out vec4 fragColor; - -void main() { - fragColor = vColor; - DECKGL_FILTER_COLOR(fragColor, geometry); -} -`; -``` - -- [ ] **Step 3: Create `src/shaders/index.ts`** - -```ts -// src/shaders/index.ts -/** - * Assembles the final HEALPix cells vertex and fragment shaders from - * focused modules. Leaf files contain only GLSL string literals; this - * file owns dependency ordering. - */ -import { INT64_GLSL } from './int64.glsl'; -import { FP64_GLSL } from './fp64.glsl'; -import { HEALPIX_DECOMPOSE_GLSL } from './healpix-decompose.glsl'; -import { HEALPIX_CORNERS_GLSL } from './healpix-corners.glsl'; -import { HEALPIX_CELLS_VS_MAIN } from './healpix-cells.vs.glsl'; -import { HEALPIX_CELLS_FS } from './healpix-cells.fs.glsl'; - -const VS_HEADER = /* glsl */ `\ -#version 300 es -#define SHADER_NAME healpix-cells-vertex -precision highp float; -precision highp int; -`; - -export const HEALPIX_VERTEX_SHADER: string = [ - VS_HEADER, - INT64_GLSL, - FP64_GLSL, - HEALPIX_DECOMPOSE_GLSL, - HEALPIX_CORNERS_GLSL, - HEALPIX_CELLS_VS_MAIN -].join('\n'); - -export const HEALPIX_FRAGMENT_SHADER: string = HEALPIX_CELLS_FS; -``` - -- [ ] **Step 4: Verify build** - -Run: `npm test && npm run build` -Expected: PASS; build output contains the new modules. The primitive layer still imports the old transitional shim from `healpix-corners.glsl.ts` — not broken. - -- [ ] **Step 5: Commit** - -```bash -git add src/shaders/healpix-cells.vs.glsl.ts \ - src/shaders/healpix-cells.fs.glsl.ts \ - src/shaders/index.ts -git commit -m "feat: add shaders/index.ts assembler and vs/fs glue modules" -``` - ---- - -## Task 8: Extend `healpix-cells-shader-module.ts` with new uniforms - -**Files:** -- Modify: `src/shaders/healpix-cells-shader-module.ts` - -- [ ] **Step 1: Replace file contents** - -```ts -// src/shaders/healpix-cells-shader-module.ts -import type { ShaderModule } from '@luma.gl/shadertools'; - -export type HealpixSchemeCode = 0 | 1; // 0 = nest, 1 = ring - -export type HealpixCellsProps = { - nside: number; - log2nside: number; - scheme: HealpixSchemeCode; - polarLim: [number, number]; - eqLim: [number, number]; - npix: [number, number]; -}; - -/** Split a non-negative JS number (≤ 2^53 - 1) into [lo, hi] u32 halves. */ -export function splitU53(x: number): [number, number] { - return [x >>> 0, Math.floor(x / 4294967296)]; -} - -/** - * Compute the per-draw uniforms the GPU decoders need from `nside` + `scheme`. - * Cheap: a handful of multiplies and two u64 splits per draw. - */ -export function computeHealpixCellsUniforms( - nside: number, - scheme: 'nest' | 'ring' -): HealpixCellsProps { - const polarLimN = 2 * nside * (nside - 1); - const npixN = 12 * nside * nside; - const eqLimN = polarLimN + 8 * nside * nside; - return { - nside, - log2nside: Math.round(Math.log2(nside)), - scheme: scheme === 'nest' ? 0 : 1, - polarLim: splitU53(polarLimN), - eqLim: splitU53(eqLimN), - npix: splitU53(npixN) - }; -} - -export const healpixCellsShaderModule = { - name: 'healpixCells', - vs: `\ -uniform healpixCellsUniforms { - uint nside; - uint log2nside; - uint scheme; - uvec2 polarLim; - uvec2 eqLim; - uvec2 npix; -} healpixCells; -`, - uniformTypes: { - nside: 'u32', - log2nside: 'u32', - scheme: 'u32', - polarLim: 'vec2', - eqLim: 'vec2', - npix: 'vec2' - } -} as const satisfies ShaderModule; -``` - -Note on `uniformTypes`: luma.gl's uniform type string for a uvec2 is `'vec2'`. If a future luma version rejects this at runtime, split each uvec2 into two `u32` uniforms (`polarLimLo` / `polarLimHi`, etc.) and update the GLSL uniform block accordingly. - -- [ ] **Step 2: Verify build** - -Run: `npm run build` -Expected: PASS. No test exercises this module directly; correctness confirmed in Task 9 when the layer wires it up. - -- [ ] **Step 3: Commit** - -```bash -git add src/shaders/healpix-cells-shader-module.ts -git commit -m "feat: extend healpixCells shader module with log2nside/scheme/ring uniforms" -``` - ---- - -## Task 9: Add `splitCellIds` + wire new attributes through both layers - -**Files:** -- Create: `src/utils/split-cell-ids.ts` -- Create: `src/utils/split-cell-ids.test.ts` -- Modify: `src/layers/healpix-cells-layer.ts` -- Modify: `src/layers/healpix-cells-primitive-layer.ts` -- Modify: `src/shaders/healpix-corners.glsl.ts` (remove transitional shim) -- Delete: `src/utils/decompose-cell-ids.ts` -- Delete: `src/utils/decompose-cell-ids.test.ts` - -This task is larger because the pieces must ship together for the app to run. Order the steps so each intermediate state compiles. - -- [ ] **Step 1: Write the failing test for `splitCellIds`** - -```ts -// src/utils/split-cell-ids.test.ts -import { splitCellIds, getSharedZeroU32 } from './split-cell-ids'; - -describe('splitCellIds', () => { - it('Uint32Array input: lo aliases, hi is shared zero buffer', () => { - const ids = new Uint32Array([0, 1, 42, 0xffffffff]); - const { cellIdLo, cellIdHi } = splitCellIds(ids); - expect(cellIdLo.buffer).toBe(ids.buffer); - expect(cellIdLo.length).toBe(4); - expect(Array.from(cellIdLo)).toEqual([0, 1, 42, 0xffffffff]); - expect(cellIdHi.length).toBeGreaterThanOrEqual(4); - expect(Array.from(cellIdHi.slice(0, 4))).toEqual([0, 0, 0, 0]); - // hi is the shared buffer - expect(cellIdHi).toBe(getSharedZeroU32(4)); - }); - - it('Int32Array input with non-negative values: lo aliases bytes', () => { - const ids = new Int32Array([0, 1, 2147483647]); - const { cellIdLo, cellIdHi } = splitCellIds(ids); - expect(cellIdLo.buffer).toBe(ids.buffer); - expect(Array.from(cellIdLo)).toEqual([0, 1, 2147483647]); - expect(Array.from(cellIdHi.slice(0, 3))).toEqual([0, 0, 0]); - }); - - it('Float64Array input: runs split loop', () => { - const TWO32 = 4294967296; - const ids = new Float64Array([0, 1, TWO32, TWO32 + 7, 12 * 2 ** 48 - 1]); - const { cellIdLo, cellIdHi } = splitCellIds(ids); - expect(Array.from(cellIdLo)).toEqual([ - 0, - 1, - 0, - 7, - ((12 * 2 ** 48 - 1) >>> 0) - ]); - expect(Array.from(cellIdHi)).toEqual([ - 0, - 0, - 1, - 1, - Math.floor((12 * 2 ** 48 - 1) / TWO32) - ]); - // Does not alias source - expect(cellIdLo.buffer).not.toBe(ids.buffer); - }); - - it('Float32Array input: runs split loop', () => { - const ids = new Float32Array([0, 1, 42]); - const { cellIdLo, cellIdHi } = splitCellIds(ids); - expect(Array.from(cellIdLo)).toEqual([0, 1, 42]); - expect(Array.from(cellIdHi)).toEqual([0, 0, 0]); - expect(cellIdLo.buffer).not.toBe(ids.buffer); - }); - - it('empty input produces empty buffers', () => { - const { cellIdLo, cellIdHi } = splitCellIds(new Uint32Array(0)); - expect(cellIdLo.length).toBe(0); - expect(cellIdHi.length).toBeGreaterThanOrEqual(0); - }); - - it('shared zero buffer grows on demand', () => { - splitCellIds(new Uint32Array(4)); - const small = getSharedZeroU32(4); - splitCellIds(new Uint32Array(10)); - const large = getSharedZeroU32(10); - expect(large.length).toBeGreaterThanOrEqual(10); - // The earlier reference may or may not still point at the same buffer - // depending on growth strategy; large must always zero-fill. - expect(Array.from(large.slice(0, 10))).toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); - // Silence unused - void small; - }); -}); -``` - -- [ ] **Step 2: Run test to verify failure** - -Run: `npm test -- split-cell-ids` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Implement `splitCellIds`** - -```ts -// src/utils/split-cell-ids.ts -import type { CellIdArray } from '../types/cell-ids'; - -const TWO32 = 4294967296; - -let sharedZero: Uint32Array = new Uint32Array(0); - -export function getSharedZeroU32(minLength: number): Uint32Array { - if (sharedZero.length < minLength) { - sharedZero = new Uint32Array(Math.max(minLength, 1024)); - } - return sharedZero; -} - -export type SplitCellIds = { - cellIdLo: Uint32Array; - cellIdHi: Uint32Array; -}; - -/** - * Split cell IDs into low/high u32 halves suitable for upload as two - * deck.gl instance attributes composed to uvec2 in the vertex shader. - * - * - Uint32Array / Int32Array inputs: cellIdLo aliases the input's bytes - * (zero copy) and cellIdHi is a shared zero buffer sized ≥ n. - * - Float64Array / Float32Array: runs a JS split loop. - */ -export function splitCellIds(cellIds: CellIdArray): SplitCellIds { - const n = cellIds.length; - if (cellIds instanceof Uint32Array) { - return { - cellIdLo: cellIds, - cellIdHi: getSharedZeroU32(n) - }; - } - if (cellIds instanceof Int32Array) { - return { - cellIdLo: new Uint32Array(cellIds.buffer, cellIds.byteOffset, n), - cellIdHi: getSharedZeroU32(n) - }; - } - const lo = new Uint32Array(n); - const hi = new Uint32Array(n); - for (let i = 0; i < n; i++) { - const id = cellIds[i]; - lo[i] = id >>> 0; - hi[i] = Math.floor(id / TWO32); - } - return { cellIdLo: lo, cellIdHi: hi }; -} -``` - -- [ ] **Step 4: Run test to verify pass** - -Run: `npm test -- split-cell-ids` -Expected: PASS. - -- [ ] **Step 5: Commit the util** - -```bash -git add src/utils/split-cell-ids.ts src/utils/split-cell-ids.test.ts -git commit -m "feat: add splitCellIds utility" -``` - -- [ ] **Step 6: Update `HealpixCellsPrimitiveLayer`** - -Replace `src/layers/healpix-cells-primitive-layer.ts` with: - -```ts -import { - DefaultProps, - Layer, - LayerContext, - picking, - project32, - UpdateParameters -} from '@deck.gl/core'; -import { Geometry, Model } from '@luma.gl/engine'; -import type { RenderPass } from '@luma.gl/core'; -import { - HEALPIX_FRAGMENT_SHADER, - HEALPIX_VERTEX_SHADER -} from '../shaders'; -import { - healpixCellsShaderModule, - computeHealpixCellsUniforms -} from '../shaders/healpix-cells-shader-module'; - -export type HealpixCellsPrimitiveLayerProps = { - nside: number; - scheme: 'nest' | 'ring'; - instanceCount: number; -}; - -type _HealpixCellsPrimitiveLayerProps = HealpixCellsPrimitiveLayerProps; -type HealpixCellsPrimitiveLayerMergedProps = _HealpixCellsPrimitiveLayerProps & - import('@deck.gl/core').LayerProps; - -const defaultProps: DefaultProps<_HealpixCellsPrimitiveLayerProps> = { - nside: { type: 'number', value: 1 }, - // @ts-expect-error deck.gl DefaultProps has no 'string' type. - scheme: { type: 'string', value: 'nest' }, - instanceCount: { type: 'number', value: 0 } -}; - -const QUAD_INDICES = new Uint16Array([0, 1, 2, 0, 2, 3]); -const QUAD_POSITIONS = new Float32Array(12); - -export class HealpixCellsPrimitiveLayer extends Layer { - static layerName = 'HealpixCellsPrimitiveLayer'; - static defaultProps = defaultProps; - - declare state: { model: Model | null }; - - getNumInstances(): number { - return this.props.instanceCount; - } - - getShaders(): ReturnType { - return super.getShaders({ - vs: HEALPIX_VERTEX_SHADER, - fs: HEALPIX_FRAGMENT_SHADER, - modules: [project32, picking, healpixCellsShaderModule] - }); - } - - initializeState(_context: LayerContext): void { - this.getAttributeManager()!.addInstanced({ - cellIdLo: { size: 1, type: 'uint32', noAlloc: true }, - cellIdHi: { size: 1, type: 'uint32', noAlloc: true } - }); - } - - updateState(params: UpdateParameters): void { - super.updateState(params); - if (params.changeFlags.extensionsChanged || !this.state.model) { - this.state.model?.destroy(); - this.state.model = this._getModel(); - this.getAttributeManager()!.invalidateAll(); - } - } - - finalizeState(context: LayerContext): void { - super.finalizeState(context); - this.state.model?.destroy(); - } - - draw({ renderPass }: { renderPass: RenderPass }): void { - const { model } = this.state; - if (!model || this.props.instanceCount === 0) return; - - model.shaderInputs.setProps({ - healpixCells: computeHealpixCellsUniforms( - this.props.nside, - this.props.scheme - ) - }); - model.setInstanceCount(this.props.instanceCount); - model.draw(renderPass); - } - - private _getModel(): Model { - const parameters = - this.context.device.type === 'webgpu' - ? { - depthWriteEnabled: true, - depthCompare: 'less-equal' as const - } - : undefined; - - return new Model(this.context.device, { - ...this.getShaders(), - id: `${this.props.id}-model`, - bufferLayout: this.getAttributeManager()!.getBufferLayouts(), - geometry: new Geometry({ - topology: 'triangle-list', - attributes: { - indices: QUAD_INDICES, - positions: { size: 3, value: QUAD_POSITIONS } - } - }), - isInstanced: true, - parameters - }); - } -} -``` - -- [ ] **Step 7: Update `HealpixCellsLayer` (composite)** - -Replace the imports and the `_decomposeCellIds` / state-shape / `renderLayers` portions of `src/layers/healpix-cells-layer.ts`: - -```ts -import { - CompositeLayer, - DefaultProps, - Layer, - LayerExtension, - UpdateParameters -} from '@deck.gl/core'; -import type { Texture } from '@luma.gl/core'; -import { splitCellIds } from '../utils/split-cell-ids'; -import { HealpixCellsPrimitiveLayer } from './healpix-cells-primitive-layer'; -import { HEALPIX_COLOR_FRAMES_EXTENSION } from '../extensions/healpix-color-frames-extension'; -import type { CellIdArray } from '../types/cell-ids'; -import type { HealpixCellsLayerProps } from '../types/layer-props'; - -type _HealpixCellsLayerProps = { - nside: number; - cellIds: CellIdArray; - scheme: 'nest' | 'ring'; - colorFrames: Uint8Array[]; - currentFrame: number; -}; - -type HealpixCellsLayerState = { - cellIdLo: Uint32Array; - cellIdHi: Uint32Array; - frameTexture: Texture | null; - cellTextureWidth: number; - frameCount: number; -}; - -type TextureData = { - colors: Uint8Array; - width: number; - height: number; - depth: number; - frameCount: number; -}; - -const EMPTY_RGBA_TEXEL = new Uint8Array([0, 0, 0, 0]); - -const defaultProps: DefaultProps<_HealpixCellsLayerProps> = { - nside: { type: 'number', value: 0 }, - cellIds: { type: 'object', value: new Uint32Array(0), compare: true }, - // @ts-expect-error deck.gl DefaultProps has no 'string' type. - scheme: { type: 'string', value: 'nest' }, - colorFrames: { type: 'object', value: [], compare: true }, - currentFrame: { type: 'number', value: 0 } -}; - -export class HealpixCellsLayer extends CompositeLayer { - static layerName = 'HealpixCellsLayer'; - static defaultProps = defaultProps; - - declare state: HealpixCellsLayerState; - - initializeState(): void { - this.setState({ - cellIdLo: new Uint32Array(0), - cellIdHi: new Uint32Array(0), - frameTexture: null, - cellTextureWidth: 1, - frameCount: 0 - }); - this._splitCellIds(); - this._updateColorTexture(); - } - - shouldUpdateState({ changeFlags }: UpdateParameters): boolean { - return !!changeFlags.propsOrDataChanged; - } - - updateState({ props, oldProps }: UpdateParameters): void { - if (props.cellIds !== oldProps.cellIds) { - this._splitCellIds(); - } - if ( - props.cellIds !== oldProps.cellIds || - props.colorFrames !== oldProps.colorFrames - ) { - this._updateColorTexture(); - } - } - - finalizeState(): void { - this.state.frameTexture?.destroy(); - } - - renderLayers(): Layer[] { - const { cellIdLo, cellIdHi, frameTexture, cellTextureWidth, frameCount } = - this.state; - const { cellIds, nside, scheme, currentFrame } = this.props; - const count = cellIds.length; - if (count === 0 || !frameTexture) return []; - - const frameIndex = Math.max( - 0, - Math.min(frameCount - 1, Math.floor(currentFrame)) - ); - - return [ - new HealpixCellsPrimitiveLayer( - this.getSubLayerProps({ - id: 'cells', - nside, - scheme, - instanceCount: count, - data: { - length: count, - attributes: { - cellIdLo: { value: cellIdLo, size: 1 }, - cellIdHi: { value: cellIdHi, size: 1 } - } - }, - frameTexture, - frameIndex, - cellTextureWidth, - extensions: [ - ...((this.props.extensions as LayerExtension[]) || []), - HEALPIX_COLOR_FRAMES_EXTENSION - ] - }) - ) - ]; - } - - private _splitCellIds(): void { - const { cellIds } = this.props; - if (!cellIds?.length) { - this.setState({ - cellIdLo: new Uint32Array(0), - cellIdHi: new Uint32Array(0) - }); - return; - } - const { cellIdLo, cellIdHi } = splitCellIds(cellIds); - this.setState({ cellIdLo, cellIdHi }); - } - - // _updateColorTexture and _buildTextureData are unchanged from the existing - // file — keep them verbatim below this line. -} -``` - -Keep `_updateColorTexture` and `_buildTextureData` exactly as they are in the existing file (lines 167 onward — the color-texture path is out of scope for this change). - -- [ ] **Step 8: Remove the transitional shim from `healpix-corners.glsl.ts`** - -Open `src/shaders/healpix-corners.glsl.ts` and delete the `/** @deprecated … */` `HEALPIX_VERTEX_SHADER` and `HEALPIX_FRAGMENT_SHADER` exports added in Task 5. The file's only export is now `HEALPIX_CORNERS_GLSL`. - -- [ ] **Step 9: Delete `decomposeCellIds`** - -```bash -git rm src/utils/decompose-cell-ids.ts src/utils/decompose-cell-ids.test.ts -``` - -- [ ] **Step 10: Run all tests** - -Run: `npm test` -Expected: PASS. No references to `decomposeCellIds` anywhere. - -- [ ] **Step 11: Build** - -Run: `npm run build` -Expected: PASS. `dist/` output has no reference to `decompose-cell-ids` and imports `shaders/index.ts` transitively through the primitive layer. - -- [ ] **Step 12: Visual regression check** - -Open the demo page (whatever loads `HealpixCellsLayer` in dev). At `nside ∈ {64, 1024, 262144}` with both `scheme: 'nest'` and `scheme: 'ring'`, compare against the last known-good build of main. Rendered cells must be pixel-identical. - -- [ ] **Step 13: Commit** - -```bash -git add -A -git commit -m "feat: move NEST/RING decode to GPU; drop decomposeCellIds" -``` - ---- - -## Task 10: Move and update NEST readback tests to `test/gpu/` - -**Files:** -- Move: `tmp/gpu-readback.html` → `test/gpu/gpu-readback-nest-equatorial.html` -- Move: `tmp/gpu-readback-polar.html` → `test/gpu/gpu-readback-nest-polar.html` -- Move: `tmp/inspect-cell.mjs` → `test/gpu/inspect-cell.mjs` -- Move: `tmp/compute-truth.mjs` → `test/gpu/compute-truth.mjs` - -Each readback page embeds the production shader inline. Update the inline shader to mirror the new assembly (int64 + fp64 + decompose + corners + vs-main) and the attributes to `cellIdLo` / `cellIdHi`. Also emit decode output (face/ix/iy) in the transform-feedback varyings. - -- [ ] **Step 1: Move the files** - -```bash -mkdir -p test/gpu -git mv tmp/gpu-readback.html test/gpu/gpu-readback-nest-equatorial.html -git mv tmp/gpu-readback-polar.html test/gpu/gpu-readback-nest-polar.html -git mv tmp/inspect-cell.mjs test/gpu/inspect-cell.mjs -git mv tmp/compute-truth.mjs test/gpu/compute-truth.mjs -rmdir tmp 2>/dev/null || true -``` - -- [ ] **Step 2: Update `test/gpu/inspect-cell.mjs` header and packing info** - -Open the file and replace the "faceIx (u32 hex)" / "instIy (u32 hex)" print block (lines near the top that show the attribute packing) with: - -```js -const cellIdLo = cellId >>> 0; -const cellIdHi = Math.floor(cellId / 4294967296); -console.log(` cellIdLo (u32 hex): 0x${cellIdLo.toString(16).padStart(8, '0')}`); -console.log(` cellIdHi (u32 hex): 0x${cellIdHi.toString(16).padStart(8, '0')}`); -``` - -No other logic changes — the `fxyCorners` + `fxy2tu` + `tu2za` sections still produce the correct truth. - -- [ ] **Step 3: Update `test/gpu/compute-truth.mjs` packing block** - -Find the block that prints `faceIx (u32 hex)` / `instIy (u32 hex)` (around line 19 of the current file) and replace with: - -```js -const cellIdLo = cellId >>> 0; -const cellIdHi = Math.floor(cellId / 4294967296); -console.log('cellIdLo (u32 hex):', cellIdLo.toString(16).padStart(8, '0')); -console.log('cellIdHi (u32 hex):', cellIdHi.toString(16).padStart(8, '0')); -``` - -Also add, after the initial `nest2fxy` print: - -```js -console.log('\nFor GPU decode tests, expected (face, ix, iy) from decodeNest:'); -console.log(` face=${f} ix=${x} iy=${y}`); -``` - -- [ ] **Step 4: Rewrite `test/gpu/gpu-readback-nest-equatorial.html`** - -The shader inside the page needs to become a full decode+corner composition. Replace the entire ` + + + diff --git a/examples/sandbox/index.html b/examples/sandbox/index.html index bd4ccf4..76f9e16 100644 --- a/examples/sandbox/index.html +++ b/examples/sandbox/index.html @@ -79,6 +79,24 @@ }); + From 50c48505d932d682da833562cacadc632979a244 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 12 May 2026 12:02:17 +0100 Subject: [PATCH 24/24] Bump version [ci skip] --- examples/_shared/package.json | 4 ++-- examples/sandbox/package.json | 2 +- lerna.json | 9 ++++++--- package-lock.json | 6 +++--- packages/deck.gl-healpix/package.json | 4 ++-- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/examples/_shared/package.json b/examples/_shared/package.json index ce652fa..0393202 100644 --- a/examples/_shared/package.json +++ b/examples/_shared/package.json @@ -2,7 +2,7 @@ "name": "deck.gl-healpix-demos-shared", "private": true, "description": "Shared components and utilities for deck.gl-healpix demos.", - "version": "1.0.0", + "version": "0.2.0", "license": "MIT", "exports": { "./*": "./*" @@ -17,4 +17,4 @@ "polished": "^4.3.1", "react": "^19.2.4" } -} \ No newline at end of file +} diff --git a/examples/sandbox/package.json b/examples/sandbox/package.json index 2f50f80..ab98b5d 100644 --- a/examples/sandbox/package.json +++ b/examples/sandbox/package.json @@ -2,7 +2,7 @@ "name": "deck.gl-healpix-sandbox", "private": true, "description": "Sandbox for deck.gl-healpix.", - "version": "1.0.0", + "version": "0.2.0", "author": { "name": "Daniel da Silva", "email": "daniel@develpmentseed.org", diff --git a/lerna.json b/lerna.json index 0624888..fe051bd 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,9 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "0.1.0", - "packages": ["packages/*", "examples/*"], + "version": "0.2.0", + "packages": [ + "packages/*", + "examples/*" + ], "npmClient": "npm" -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7718872..fc92ee5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ }, "examples/_shared": { "name": "deck.gl-healpix-demos-shared", - "version": "1.0.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@chakra-ui/react": "^3.34.0", @@ -53,7 +53,7 @@ }, "examples/sandbox": { "name": "deck.gl-healpix-sandbox", - "version": "1.0.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@chakra-ui/react": "^3.35.0", @@ -17986,7 +17986,7 @@ }, "packages/deck.gl-healpix": { "name": "@developmentseed/deck.gl-healpix", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "healpix-ts": "^1.0.0" diff --git a/packages/deck.gl-healpix/package.json b/packages/deck.gl-healpix/package.json index b1c41af..9d0e6dd 100644 --- a/packages/deck.gl-healpix/package.json +++ b/packages/deck.gl-healpix/package.json @@ -1,6 +1,6 @@ { "name": "@developmentseed/deck.gl-healpix", - "version": "0.1.0", + "version": "0.2.0", "description": "HEALPix (Hierarchical Equal Area isoLatitude Pixelization) implementation in Deck.gl", "main": "dist/index.js", "module": "dist/index.mjs", @@ -56,4 +56,4 @@ "devDependencies": { "healpix-ts": "^1.0.0" } -} \ No newline at end of file +}