diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..b816cec --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,95 @@ +# This workflow performs basic checks: +# +# 1. run a preparation step to install and cache node modules +# 2. once prep succeeds, lint and test run in parallel +# +# The checks only run on non-draft Pull Requests. They don't run on the main +# branch prior to deploy. It's recommended to use branch protection to avoid +# pushes straight to 'main'. + +name: Checks + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + prep: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + + - name: restore lerna + uses: actions/cache@v5 + with: + path: '**/node_modules' + key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} + + - name: Install + run: npm install + + lint: + needs: prep + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + + - name: restore lerna + uses: actions/cache@v5 + with: + path: '**/node_modules' + key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} + + - name: Install + run: npm install + + - name: Lint + run: npm run lint + + test: + needs: prep + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + + - name: restore lerna + uses: actions/cache@v5 + with: + path: '**/node_modules' + key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} + + - name: Install + run: npm install + + - name: Test + run: npm run test \ No newline at end of file diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..4e9a039 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,78 @@ +name: Deploy Docs + +on: + push: + tags: + - "v*" + - "!v*-alpha*" + - "!v*-beta*" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +env: + VITE_MAPTILER_KEY: ${{ secrets.MAPTILER_KEY }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: npm + + - name: Install + run: npm i + + - name: Build packages + run: npm run build -- --scope '@developmentseed/*' + + - name: Build examples + run: | + mkdir -p dist/examples + for dir in examples/*/; do + example=$(basename "$dir") + if [[ "$example" == "_shared" ]]; then + continue + fi + + echo "Building example: $example" + cd $dir + cp .env.example .env + export VITE_BASE_URL=https://deck.gl-healpix.ds.io/examples/$example + npm run build + cd ../.. + echo "Copying example: $example" + mkdir -p "dist/examples/${example}" + cp -r "${dir}dist/." "dist/examples/${example}/" + done + + - name: Copy 404.html + run: cp examples/_shared/404.html dist/404.html + + - uses: actions/upload-pages-artifact@v4 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 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/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/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/_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/_shared/components/control-panel.tsx b/examples/_shared/components/control-panel.tsx new file mode 100644 index 0000000..41bbfca --- /dev/null +++ b/examples/_shared/components/control-panel.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { Flex, Heading, IconButton, Text } from '@chakra-ui/react'; +import { + CollecticonSlidersHorizontal, + CollecticonXmarkSmall +} from '@devseed-ui/collecticons-chakra'; + +export function ControlPanel({ + children, + title, + description +}: { + children: React.ReactNode; + title?: string; + description?: string; +}) { + const [isOpen, setIsOpen] = useState(true); + + return ( + <> + {!isOpen && ( + setIsOpen(true)} + aria-label='Show controls' + > + + + )} + + {isOpen && ( + + setIsOpen(false)} + aria-label='Hide controls' + position='absolute' + top={2} + right={2} + zIndex={1001} + > + + + {(title || description) && ( + + {title && {title}} + + {description && ( + + {description} + + )} + + )} + {children} + + )} + + ); +} diff --git a/examples/sandbox/public/logo.svg b/examples/_shared/components/logo.svg similarity index 100% rename from examples/sandbox/public/logo.svg rename to examples/_shared/components/logo.svg diff --git a/examples/_shared/components/page-layout.tsx b/examples/_shared/components/page-layout.tsx index 6a0c438..58fa26b 100644 --- a/examples/_shared/components/page-layout.tsx +++ b/examples/_shared/components/page-layout.tsx @@ -1,8 +1,36 @@ -import { Button, ChakraProvider, Heading, Image, Flex } from '@chakra-ui/react'; -import { BrowserRouter, NavLink, NavLinkProps } from 'react-router'; +import { useState } from 'react'; +import { + Button, + ChakraProvider, + Drawer, + Heading, + Image, + Flex, + Separator, + Link, + IconButton, + LinkProps +} from '@chakra-ui/react'; +import { + CollecticonBrandGithub, + CollecticonHamburgerMenu, + CollecticonXmarkSmall +} from '@devseed-ui/collecticons-chakra'; +import { BrowserRouter, NavLink } from 'react-router'; import system from '../styles/theme'; -export function PageNavLink(props: NavLinkProps) { +import logo from './logo.svg'; + +// If using a router add the public url to the base path. +const publicUrl = import.meta.env.VITE_BASE_URL || ''; + +const baseName = new URL( + publicUrl.startsWith('http') + ? publicUrl + : `https://ds.io/${publicUrl.replace(/^\//, '')}` +).pathname; + +export function PageNavLink(props: LinkProps & { to: string }) { return ( ); } +export type NavItem = { label: string; to: string }; + // Root component. export function PageLayout(props: { children?: React.ReactNode; title?: string; - navSlot?: React.ReactNode; + navItems?: NavItem[]; }) { - const { children, title = import.meta.env.VITE_APP_TITLE, navSlot } = props; + const { children, title = import.meta.env.VITE_APP_TITLE, navItems } = props; + const [menuOpen, setMenuOpen] = useState(false); return ( - - - - - Logo - {title} - - {navSlot} + + setMenuOpen(e.open)}> + + + + Logo + {title} + + + {navItems && ( + + {navItems.map((item) => ( + + {item.label} + + ))} + + + )} + + + + + + {navItems && ( + setMenuOpen(true)} + > + + + )} + + + + {children} - {children} - + + + + + + + + + + Menu + + + {navItems?.map((item) => ( + setMenuOpen(false)} + justifyContent='flex-start' + px={4} + > + {item.label} + + ))} + + + + ); diff --git a/examples/_shared/package.json b/examples/_shared/package.json index bde697c..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": { "./*": "./*" @@ -10,10 +10,11 @@ "dependencies": { "@chakra-ui/react": "^3.34.0", "@deck.gl/mapbox": "^9.3.1", + "@devseed-ui/collecticons-chakra": "^4.0.0", "@types/d3": "^7.4.3", "d3": "^7.9.0", "maplibre-gl": "^5.21.0", "polished": "^4.3.1", "react": "^19.2.4" } -} \ No newline at end of file +} diff --git a/examples/_shared/tsconfig.json b/examples/_shared/tsconfig.json new file mode 100644 index 0000000..cd20dd9 --- /dev/null +++ b/examples/_shared/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "isolatedModules": true, + "noEmit": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["./**/*.ts", "./**/*.tsx", "./vite-env.d.ts"] +} diff --git a/examples/_shared/vite-env.d.ts b/examples/_shared/vite-env.d.ts new file mode 100644 index 0000000..f3d6622 --- /dev/null +++ b/examples/_shared/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_APP_TITLE?: string; + readonly VITE_MAPBOX_ACCESS_TOKEN?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/examples/sandbox/.env.example b/examples/sandbox/.env.example index 873dabf..0062ef5 100644 --- a/examples/sandbox/.env.example +++ b/examples/sandbox/.env.example @@ -16,3 +16,7 @@ VITE_APP_DESCRIPTION=Demos for deck.gl-healpix. # Optional: MapTiler API key for map basemap tiles. For production, use your own key. # VITE_MAPTILER_KEY= + +# URL for serving static files. +# http-server -p 8888 --gzip --cors +#VITE_STATIC_FILES_URL= \ No newline at end of file diff --git a/examples/sandbox/app/main.tsx b/examples/sandbox/app/main.tsx index c0808ae..23da264 100644 --- a/examples/sandbox/app/main.tsx +++ b/examples/sandbox/app/main.tsx @@ -1,11 +1,10 @@ import { useEffect } from 'react'; import { createRoot } from 'react-dom/client'; import { Route, Routes } from 'react-router'; -import { Flex } from '@chakra-ui/react'; import PageAnimation from '$pages/animation'; import PageColor from '$pages/color'; -import { PageLayout, PageNavLink } from '$shared/components/page-layout'; +import { PageLayout } from '$shared/components/page-layout'; // Root component. function Root() { @@ -16,12 +15,10 @@ function Root() { return ( - Cell Rendering - Color Visualization - - } + navItems={[ + { label: 'Cell Rendering', to: '/' }, + { label: 'Color Visualization', to: '/color' } + ]} > } /> diff --git a/examples/sandbox/app/pages/animation/index.tsx b/examples/sandbox/app/pages/animation/index.tsx index 87a7d09..98cd3f0 100644 --- a/examples/sandbox/app/pages/animation/index.tsx +++ b/examples/sandbox/app/pages/animation/index.tsx @@ -10,6 +10,7 @@ import { Slider, Text } from '@chakra-ui/react'; +import { ControlPanel } from '$shared/components/control-panel'; import { HealpixCellsLayer, HealpixScheme, @@ -146,28 +147,10 @@ export default function PageAnimation() { return ( - {/* ── Controls panel (top-left) ── */} - - - HEALPix cells dynamically generated -
- - Half of the total cells for the chosen nside are generated and - animated, shifting them over the different frames. - -
{/* Projection dropdown */} @@ -250,7 +233,7 @@ export default function PageAnimation() { FPS: {fps.toFixed(1)}
-
+ {/* Map area */} ('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 +113,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 +125,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( () => @@ -127,90 +141,50 @@ 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, - min: indexMin, - max: indexMax, - 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, - min: indexMin, - max: indexMax, - 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, - 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, colorMap, indexMin, indexMax]); + }, [ + zarrData, + ndvi, + singleBand, + selectorModule, + colorMap, + rescaleMin, + rescaleMax, + filterMin, + filterMax + ]); return ( - - - 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. - -
- {loadPending && Loading Zarr…} {loadError && ( @@ -262,23 +236,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 +258,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'} + )} @@ -301,7 +309,7 @@ export default function PageColor() { onSchemeChange={setColorScheme} /> )} -
+ ); } + +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 } > = { diff --git a/examples/sandbox/index.html b/examples/sandbox/index.html index 12c8f8b..76f9e16 100644 --- a/examples/sandbox/index.html +++ b/examples/sandbox/index.html @@ -48,7 +48,7 @@
- Development Seed logotype + Development Seed logotype

In the beginning the Universe was created.

This has made a lot of people very angry and been widely regarded as a bad move.

@@ -79,6 +79,24 @@ }); + diff --git a/examples/sandbox/package.json b/examples/sandbox/package.json index 5bac2b9..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", @@ -42,6 +42,7 @@ "@deck.gl/mapbox": "^9.3.1", "@deck.gl/react": "^9.3.1", "@developmentseed/deck.gl-healpix": "*", + "@devseed-ui/collecticons-chakra": "^4.0.0", "@emotion/react": "^11.14.0", "@types/d3": "^7.4.3", "d3": "^7.9.0", @@ -51,7 +52,7 @@ "react-dom": "^19.2.5", "react-map-gl": "^8.1.1", "react-router": "^7.14.2", - "zarrita": "^0.6.2" + "zarrita": "^0.6.1" }, "alias": { "$components": "~/app/components", diff --git a/examples/sandbox/public/meta/icon.svg b/examples/sandbox/public/meta/icon.svg new file mode 100644 index 0000000..072ba09 --- /dev/null +++ b/examples/sandbox/public/meta/icon.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/sandbox/tsconfig.app.json b/examples/sandbox/tsconfig.app.json index 3ba36ed..49f028b 100644 --- a/examples/sandbox/tsconfig.app.json +++ b/examples/sandbox/tsconfig.app.json @@ -31,5 +31,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["app", "../_shared"] + "include": ["app"] } diff --git a/examples/sandbox/vite.config.mts b/examples/sandbox/vite.config.mts index c29de66..03647b0 100644 --- a/examples/sandbox/vite.config.mts +++ b/examples/sandbox/vite.config.mts @@ -16,6 +16,7 @@ const alias = Object.entries(pkg.alias).reduce((acc, [key, value]) => { // https://vite.dev/config/ export default defineConfig({ + base: process.env.VITE_BASE_URL || '/', plugins: [react()], // ES workers: IIFE is invalid when the worker graph is code-split (e.g. zarr). worker: { format: 'es' }, diff --git a/lerna.json b/lerna.json index 8c44f15..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/*"], + "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 1779490..fc92ee5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,11 +38,12 @@ }, "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", "@deck.gl/mapbox": "^9.3.1", + "@devseed-ui/collecticons-chakra": "^4.0.0", "@types/d3": "^7.4.3", "d3": "^7.9.0", "maplibre-gl": "^5.21.0", @@ -52,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", @@ -62,6 +63,7 @@ "@deck.gl/mapbox": "^9.3.1", "@deck.gl/react": "^9.3.1", "@developmentseed/deck.gl-healpix": "*", + "@devseed-ui/collecticons-chakra": "^4.0.0", "@emotion/react": "^11.14.0", "@types/d3": "^7.4.3", "d3": "^7.9.0", @@ -71,7 +73,7 @@ "react-dom": "^19.2.5", "react-map-gl": "^8.1.1", "react-router": "^7.14.2", - "zarrita": "^0.6.2" + "zarrita": "^0.6.1" }, "devDependencies": { "@types/babel__core": "^7", @@ -85,38 +87,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", @@ -978,6 +948,26 @@ "resolved": "packages/deck.gl-healpix", "link": true }, + "node_modules/@devseed-ui/collecticons-chakra": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@devseed-ui/collecticons-chakra/-/collecticons-chakra-4.0.0.tgz", + "integrity": "sha512-M+2I/CnQlfKsCxWFVheEdHyG5aE9zjVOpZaUmx5fLwd6lUw1gJm6W/P2AtLptjsB5zHrYMDGT3GeXahBzLyung==", + "license": "MIT", + "dependencies": { + "@chakra-ui/react": "^3.8.1", + "@emotion/react": "^11.14.0", + "clipboard": "^2.0.11" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@chakra-ui/react": "^3.8.1", + "@emotion/react": "^11.14.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -6598,6 +6588,16 @@ "integrity": "sha512-XUpqDtXfHe7CySjOhLPLj9H8rxbiFUJAGgmBzNdpsGPP4wx12cpOXrpSjRXZ2kMwooMPz/P7RPDBteto8sqhAQ==", "license": "MIT" }, + "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" + } + }, "node_modules/@zkochan/js-yaml": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/@zkochan/js-yaml/-/js-yaml-0.0.7.tgz", @@ -7515,6 +7515,17 @@ "node": ">= 10" } }, + "node_modules/clipboard": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz", + "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", + "license": "MIT", + "dependencies": { + "good-listener": "^1.2.2", + "select": "^1.1.2", + "tiny-emitter": "^2.0.0" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -8716,6 +8727,12 @@ "node": ">=0.4.0" } }, + "node_modules/delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", + "license": "MIT" + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", @@ -10174,6 +10191,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/good-listener": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", + "license": "MIT", + "dependencies": { + "delegate": "^3.1.2" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -15772,6 +15798,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/select": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -16605,6 +16637,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz", @@ -17112,6 +17150,18 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "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" + } + }, "node_modules/upath": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", @@ -17924,9 +17974,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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" + } + }, "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" @@ -17935,7 +17995,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/package.json b/package.json index 239bc8a..93834d5 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ ], "description": "Monorepo for HEALPix deck.gl layers and related packages", "scripts": { + "set-workspace": "export NX_WORKSPACE_ROOT_PATH=$(pwd)", "build": "lerna run build", "build:watch": "lerna run build:watch", "clean": "lerna run clean", diff --git a/packages/deck.gl-healpix/README.md b/packages/deck.gl-healpix/README.md index 2b8c8e7..c49af36 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,112 @@ 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 (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.). 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. + +> **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_* +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); +selectedValues = vec4(ndvi, 0.0, 0.0, 0.0); +` + } +}; + +const gammaRescale = { + name: 'gammaRescale', + inject: { + 'fs:HEALPIX_RESCALE_VALUES': `\ +selectedValues.x = pow(clamp(selectedValues.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 +259,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 +290,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 +312,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. diff --git a/packages/deck.gl-healpix/package.json b/packages/deck.gl-healpix/package.json index 6089213..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", @@ -50,9 +50,10 @@ "@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" } -} \ No newline at end of file +} 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'; 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..0ba7ea0 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 { 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); + 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); @@ -230,8 +227,9 @@ describe('decodeNest', () => { const nside2 = BigInt(nside) * BigInt(nside); let state = 0x9e3779b97f4a7c15n; const rand = () => { - state = (state * 6364136223846793005n + 1442695040888963407n) & - 0xffffffffffffffffn; + state = + (state * 6364136223846793005n + 1442695040888963407n) & + 0xffffffffffffffffn; return state; }; for (let trial = 0; trial < 200; trial++) { @@ -293,8 +291,9 @@ describe('decodeRing', () => { const npix = 12n * n * n; let state = (BigInt(nside) * 0x9e3779b97f4a7c15n) & 0xffffffffffffffffn; const rand = () => { - state = (state * 6364136223846793005n + 1442695040888963407n) & - 0xffffffffffffffffn; + state = + (state * 6364136223846793005n + 1442695040888963407n) & + 0xffffffffffffffffn; return state; }; const ids: bigint[] = []; diff --git a/packages/deck.gl-healpix/src/shaders/__tests__/gpu-decode-reference.ts b/packages/deck.gl-healpix/src/shaders/__tests__/gpu-decode-reference.ts index c4e1828..7bf3fc8 100644 --- a/packages/deck.gl-healpix/src/shaders/__tests__/gpu-decode-reference.ts +++ b/packages/deck.gl-healpix/src/shaders/__tests__/gpu-decode-reference.ts @@ -203,7 +203,7 @@ export function decodeRing( if (u64_lt(cellId, eqLim)) { return decodeRingEquatorial(cellId, nside, polarLim); } - return decodeRingSouth(cellId, nside, npix); + return decodeRingSouth(cellId, npix); } function decodeRingNorth(cellId: U64, nside: number): DecodeResult { @@ -247,7 +247,7 @@ function decodeRingEquatorial( return { face, ix, iy }; } -function decodeRingSouth(cellId: U64, nside: number, npix: U64): DecodeResult { +function decodeRingSouth(cellId: U64, npix: U64): DecodeResult { 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); diff --git a/packages/deck.gl-healpix/src/shaders/healpix-cells.vs.glsl.ts b/packages/deck.gl-healpix/src/shaders/healpix-cells.vs.glsl.ts index 70771d0..12d0d12 100644 --- a/packages/deck.gl-healpix/src/shaders/healpix-cells.vs.glsl.ts +++ b/packages/deck.gl-healpix/src/shaders/healpix-cells.vs.glsl.ts @@ -5,9 +5,11 @@ export const HEALPIX_CELLS_VS_MAIN: string = /* glsl */ ` in uint cellIdLo; in uint cellIdHi; +in float healpixCellIndex; in vec3 positions; out vec4 vColor; +out float vHealpixCellIndex; void main() { uvec2 cellId = uvec2(cellIdLo, cellIdHi); @@ -47,6 +49,7 @@ void main() { ); vColor = vec4(1.0); + vHealpixCellIndex = healpixCellIndex; DECKGL_FILTER_COLOR(vColor, geometry); } `; diff --git a/packages/deck.gl-healpix/src/shaders/healpix-color-pipeline.test.ts b/packages/deck.gl-healpix/src/shaders/healpix-color-pipeline.test.ts new file mode 100644 index 0000000..32c3adf --- /dev/null +++ b/packages/deck.gl-healpix/src/shaders/healpix-color-pipeline.test.ts @@ -0,0 +1,120 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { HEALPIX_CELLS_FS } from './healpix-cells.fs.glsl'; +import { HEALPIX_CELLS_VS_MAIN } from './healpix-cells.vs.glsl'; +import { healpixColorShaderModule } from './healpix-color-shader-module'; +import { healpixFilterShaderModule } from './healpix-filter-shader-module'; +import { healpixRescaleShaderModule } from './healpix-rescale-shader-module'; +import { healpixValuesShaderModule } from './healpix-values-shader-module'; + +function injectionSource( + injection: string | { injection: string; order: number } | undefined +): string { + return typeof injection === 'string' + ? injection + : (injection?.injection ?? ''); +} + +describe('HEALPix color pipeline shader modules', () => { + 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)' + ); + }); +}); 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..ab11598 --- /dev/null +++ b/packages/deck.gl-healpix/src/shaders/healpix-values-shader-module.ts @@ -0,0 +1,89 @@ +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 { + highp int uDimensions; + highp int uColorMode; + highp int uValuesWidth; + highp 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; + +highp int healpixCell; +highp int healpixDimensions; +highp 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. +// +// 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); + +float healpixValueAt(highp int channel) { + if (channel < 0 || channel >= healpixDimensions) { + return 0.0; + } + + highp int texel = channel / 4; + highp int component = channel - texel * 4; + highp int valueIndex = healpixCell * healpixValues.uTexelsPerCell + texel; + highp int x = valueIndex % healpixValues.uValuesWidth; + highp 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; 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; 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 }; 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 }; }