diff --git a/web/src/layers/cape.tsx b/web/src/layers/cape.tsx index 3742b1f..aa83a53 100644 --- a/web/src/layers/cape.tsx +++ b/web/src/layers/cape.tsx @@ -3,7 +3,7 @@ import { WEATHER_BASE } from '../config'; import type { CapeTexIndex } from '../generated/weather'; import { nearestStep } from '../Timeline'; import { age } from '../weather'; -import { CapeRasterLayer } from './capeRasterLayer'; +import { CapeRasterLayer } from './scalarRasterLayer'; import { Swatch } from './swatch'; import type { WeatherLayer } from './types'; @@ -30,8 +30,8 @@ export const cape: WeatherLayer = { new CapeRasterLayer({ id: 'cape-raster', image: `${WEATHER_BASE}/weather/capetex/${idx.snapshotMs}/${step}.png`, - capeMin: idx.capeMin, - capeMax: idx.capeMax, + min: idx.capeMin, + max: idx.capeMax, opacity: ctx.ui.opacity ?? 0.5, }), ]; diff --git a/web/src/layers/capeRasterLayer.ts b/web/src/layers/capeRasterLayer.ts deleted file mode 100644 index a2c237c..0000000 --- a/web/src/layers/capeRasterLayer.ts +++ /dev/null @@ -1,152 +0,0 @@ -// The GFS storm-potential raster: the same full-world lng/lat mesh as the wind -// backdrop (projected through deck's `project32`), but the fragment shader samples -// the single-channel `capetex` surface-CAPE texture, fades out stable air below a -// display threshold (→ transparent), and colormaps the rest on the severe-weather -// instability scale. Always a forecast (CAPE has no live analog). Mirrors -// RefcRasterLayer / WindRasterLayer. - -import { - Layer, - type LayerContext, - type LayerProps, - project32, - type UpdateParameters, -} from '@deck.gl/core'; -import { Geometry, Model } from '@luma.gl/engine'; -import { - CAPE_RAMP_GLSL, - EQUIRECT_RASTER_VS, - equirectGridMesh, - loadEquirectTexture, -} from './rasterShared'; - -// std140 UBO (luma v9 has no setUniforms): the J/kg bounds to denormalize the -// texture byte with, and the fill opacity. -const RASTER_UNIFORM_BLOCK = /* glsl */ `\ -layout(std140) uniform capeUniforms { - float capeMin; - float capeMax; - float opacity; -} raster; -`; - -// Typed `any`: luma's ShaderModule generic isn't worth threading for a local UBO. -// `name` MUST match the UBO block prefix: luma derives the block it binds as -// `${name}Uniforms` (shadertools getShaderModuleUniformBlockName), so a 'raster' -// name with a `capeUniforms` block silently fails to bind (uniforms read 0). -const capeUniforms: any = { - name: 'cape', - vs: RASTER_UNIFORM_BLOCK, - fs: RASTER_UNIFORM_BLOCK, - uniformTypes: { - capeMin: 'f32', - capeMax: 'f32', - opacity: 'f32', - }, -}; - -const FS = /* glsl */ `#version 300 es -#define SHADER_NAME cape-raster-fragment -precision highp float; -uniform sampler2D u_cape; -in vec2 v_uv; -out vec4 fragColor; -${CAPE_RAMP_GLSL} -void main() { - // Grayscale texture: CAPE (J/kg) packed in the red channel over [capeMin, capeMax]. - float cape = mix(raster.capeMin, raster.capeMax, texture(u_cape, v_uv).r); - // Stable / weakly-unstable air (below ~250 J/kg) fades out; only air primed for - // convection paints. Fade in across the threshold band so edges aren't a hard cut. - float a = smoothstep(250.0, 800.0, cape) * raster.opacity; - if (a <= 0.0) discard; - fragColor = vec4(capeRamp(cape), a); -}`; - -export type CapeRasterLayerProps = LayerProps & { - image: string; - capeMin: number; - capeMax: number; - opacity?: number; -}; - -const defaultProps = { - image: '', - capeMin: 0, - capeMax: 5000, - opacity: 0.5, -}; - -export class CapeRasterLayer extends Layer { - static layerName = 'CapeRasterLayer'; - static defaultProps = defaultProps as never; - - // Typed `any`: deck's layer state is loosely typed; the GPU resources are local. - declare state: any; - - initializeState(): void { - const { device } = this.context; - const mesh = equirectGridMesh(90, 140); - const model = new Model(device, { - id: `${this.props.id}-mesh`, - vs: EQUIRECT_RASTER_VS, - fs: FS, - modules: [project32, capeUniforms], - geometry: new Geometry({ - topology: 'triangle-list', - vertexCount: mesh.length / 2, - attributes: { a_lnglat: { size: 2, value: mesh } }, - }), - parameters: { - blend: true, - blendColorSrcFactor: 'src-alpha', - blendColorDstFactor: 'one-minus-src-alpha', - blendAlphaSrcFactor: 'one', - blendAlphaDstFactor: 'one-minus-src-alpha', - depthWriteEnabled: false, - depthCompare: 'always', - }, - disableWarnings: true, - }); - this.setState({ model }); - } - - updateState(params: UpdateParameters): void { - const { props, oldProps } = params; - if (props.image && props.image !== oldProps.image) { - void this._loadCape(props.image); - } - } - - async _loadCape(url: string): Promise { - const tex = await loadEquirectTexture(this.context.device, url).catch( - () => null, - ); - if (!tex || this.props.image !== url) { - tex?.destroy(); - return; - } - this.state.capeTexture?.destroy(); - this.setState({ capeTexture: tex }); - this.setNeedsRedraw(); - } - - draw(): void { - const { model, capeTexture } = this.state; - if (!capeTexture) return; - model.setBindings({ u_cape: capeTexture }); - model.shaderInputs.setProps({ - cape: { - capeMin: this.props.capeMin, - capeMax: this.props.capeMax, - opacity: this.props.opacity ?? 0.5, - }, - }); - model.draw(this.context.renderPass); - } - - finalizeState(context: LayerContext): void { - super.finalizeState(context); - this.state.capeTexture?.destroy(); - this.state.model?.destroy(); - } -} diff --git a/web/src/layers/precip.tsx b/web/src/layers/precip.tsx index 21c39c1..75c54dc 100644 --- a/web/src/layers/precip.tsx +++ b/web/src/layers/precip.tsx @@ -6,7 +6,7 @@ import { WEATHER_BASE } from '../config'; import type { CityTileIndex, RefcTexIndex } from '../generated/weather'; import { nearestStep } from '../Timeline'; import { age } from '../weather'; -import { RefcRasterLayer } from './refcRasterLayer'; +import { RefcRasterLayer } from './scalarRasterLayer'; import { Swatch } from './swatch'; import type { WeatherLayer } from './types'; @@ -74,8 +74,8 @@ export const precip: WeatherLayer = { new RefcRasterLayer({ id: 'precip-forecast', image: `${WEATHER_BASE}/weather/refctex/${r.refc.snapshotMs}/${r.step}.png`, - dbzMin: r.refc.dbzMin, - dbzMax: r.refc.dbzMax, + min: r.refc.dbzMin, + max: r.refc.dbzMax, opacity, }), ]; diff --git a/web/src/layers/refcRasterLayer.ts b/web/src/layers/refcRasterLayer.ts deleted file mode 100644 index 5694685..0000000 --- a/web/src/layers/refcRasterLayer.ts +++ /dev/null @@ -1,153 +0,0 @@ -// The GFS precipitation-forecast raster: the same full-world lng/lat mesh as the -// wind backdrop (projected through deck's `project32`), but the fragment shader -// samples the single-channel `refctex` dBZ texture, drops everything below a -// display threshold (clear sky → transparent), and colormaps the rest on the -// conventional radar scale. Drawn in place of the live radar tiles when the -// timeline is scrubbed into the future. Mirrors WindRasterLayer. - -import { - Layer, - type LayerContext, - type LayerProps, - project32, - type UpdateParameters, -} from '@deck.gl/core'; -import { Geometry, Model } from '@luma.gl/engine'; -import { - EQUIRECT_RASTER_VS, - equirectGridMesh, - loadEquirectTexture, - REFC_RAMP_GLSL, -} from './rasterShared'; - -// std140 UBO (luma v9 has no setUniforms): the dBZ bounds to denormalize the -// texture byte with, and the fill opacity. -const RASTER_UNIFORM_BLOCK = /* glsl */ `\ -layout(std140) uniform refcUniforms { - float dbzMin; - float dbzMax; - float opacity; -} raster; -`; - -// Typed `any`: luma's ShaderModule generic isn't worth threading for a local UBO. -// `name` MUST match the UBO block prefix: luma derives the block it binds as -// `${name}Uniforms` (shadertools getShaderModuleUniformBlockName), so a 'raster' -// name with a `refcUniforms` block silently fails to bind (uniforms read 0). -const refcUniforms: any = { - name: 'refc', - vs: RASTER_UNIFORM_BLOCK, - fs: RASTER_UNIFORM_BLOCK, - uniformTypes: { - dbzMin: 'f32', - dbzMax: 'f32', - opacity: 'f32', - }, -}; - -const FS = /* glsl */ `#version 300 es -#define SHADER_NAME refc-raster-fragment -precision highp float; -uniform sampler2D u_refc; -in vec2 v_uv; -out vec4 fragColor; -${REFC_RAMP_GLSL} -void main() { - // Grayscale texture: dBZ packed in the red channel over [dbzMin, dbzMax]. - float dbz = mix(raster.dbzMin, raster.dbzMax, texture(u_refc, v_uv).r); - // Clear sky (GFS floors no-echo at ~-20 dBZ) and faint returns fade out; only - // real precip (≳ light rain) paints. Fade in across the threshold band so the - // echo edges aren't a hard cutoff. - float a = smoothstep(8.0, 20.0, dbz) * raster.opacity; - if (a <= 0.0) discard; - fragColor = vec4(refcRamp(dbz), a); -}`; - -export type RefcRasterLayerProps = LayerProps & { - image: string; - dbzMin: number; - dbzMax: number; - opacity?: number; -}; - -const defaultProps = { - image: '', - dbzMin: -20, - dbzMax: 75, - opacity: 0.65, -}; - -export class RefcRasterLayer extends Layer { - static layerName = 'RefcRasterLayer'; - static defaultProps = defaultProps as never; - - // Typed `any`: deck's layer state is loosely typed; the GPU resources are local. - declare state: any; - - initializeState(): void { - const { device } = this.context; - const mesh = equirectGridMesh(90, 140); - const model = new Model(device, { - id: `${this.props.id}-mesh`, - vs: EQUIRECT_RASTER_VS, - fs: FS, - modules: [project32, refcUniforms], - geometry: new Geometry({ - topology: 'triangle-list', - vertexCount: mesh.length / 2, - attributes: { a_lnglat: { size: 2, value: mesh } }, - }), - parameters: { - blend: true, - blendColorSrcFactor: 'src-alpha', - blendColorDstFactor: 'one-minus-src-alpha', - blendAlphaSrcFactor: 'one', - blendAlphaDstFactor: 'one-minus-src-alpha', - depthWriteEnabled: false, - depthCompare: 'always', - }, - disableWarnings: true, - }); - this.setState({ model }); - } - - updateState(params: UpdateParameters): void { - const { props, oldProps } = params; - if (props.image && props.image !== oldProps.image) { - void this._loadRefc(props.image); - } - } - - async _loadRefc(url: string): Promise { - const tex = await loadEquirectTexture(this.context.device, url).catch( - () => null, - ); - if (!tex || this.props.image !== url) { - tex?.destroy(); - return; - } - this.state.refcTexture?.destroy(); - this.setState({ refcTexture: tex }); - this.setNeedsRedraw(); - } - - draw(): void { - const { model, refcTexture } = this.state; - if (!refcTexture) return; - model.setBindings({ u_refc: refcTexture }); - model.shaderInputs.setProps({ - refc: { - dbzMin: this.props.dbzMin, - dbzMax: this.props.dbzMax, - opacity: this.props.opacity ?? 0.65, - }, - }); - model.draw(this.context.renderPass); - } - - finalizeState(context: LayerContext): void { - super.finalizeState(context); - this.state.refcTexture?.destroy(); - this.state.model?.destroy(); - } -} diff --git a/web/src/layers/scalarRasterLayer.ts b/web/src/layers/scalarRasterLayer.ts new file mode 100644 index 0000000..5da034b --- /dev/null +++ b/web/src/layers/scalarRasterLayer.ts @@ -0,0 +1,196 @@ +// One full-world equirectangular raster of a single GFS scalar field: the same +// lng/lat mesh as the wind backdrop (projected through deck's `project32`), with +// a fragment shader that denormalizes the grayscale texture over [min, max], +// fades out below a display threshold (→ transparent), and colormaps the rest. +// The precip-forecast (REFC) and storm-potential (CAPE) layers are one config +// each on top of this base. +// +// The UBO block name is derived from the layer's `name` (luma binds the block it +// finds as `${module.name}Uniforms`), so the name/block mismatch that silently +// blanked these layers before is now unrepresentable: there is a single `name`. + +import { + Layer, + type LayerContext, + type LayerProps, + project32, + type UpdateParameters, +} from '@deck.gl/core'; +import { Geometry, Model } from '@luma.gl/engine'; +import { + CAPE_RAMP_GLSL, + EQUIRECT_RASTER_VS, + equirectGridMesh, + loadEquirectTexture, + REFC_RAMP_GLSL, +} from './rasterShared'; + +export type ScalarRasterLayerProps = LayerProps & { + image: string; + /** Denormalization bounds: texture byte 0→min, 255→max. */ + min: number; + max: number; + opacity?: number; +}; + +/** Everything that distinguishes one scalar raster from another. `name` is the + * single source for the module name, the UBO block (`${name}Uniforms`), the + * sampler (`u_${name}`), and the setProps key — so they cannot drift apart. */ +type ScalarConfig = { + name: string; + /** GLSL defining `vec3 ${rampFn}(float value)`. */ + rampGlsl: string; + rampFn: string; + /** smoothstep fade-in band over the denormalized value (below → transparent). */ + threshold: [number, number]; + defaultOpacity: number; +}; + +abstract class ScalarRasterLayer< + P extends ScalarRasterLayerProps = ScalarRasterLayerProps, +> extends Layer

{ + // Typed `any`: deck's layer state is loosely typed; the GPU resources are local. + declare state: any; + + protected abstract scalarConfig(): ScalarConfig; + + initializeState(): void { + const { name, rampGlsl, rampFn, threshold, defaultOpacity } = + this.scalarConfig(); + // std140 UBO (luma v9 has no setUniforms). `vmin`/`vmax` (not min/max — those + // are GLSL builtins) carry the denormalization bounds; opacity the fill. + const block = /* glsl */ `\ +layout(std140) uniform ${name}Uniforms { + float vmin; + float vmax; + float opacity; +} scalar; +`; + // Typed `any`: luma's ShaderModule generic isn't worth threading for a UBO. + const uniforms: any = { + name, + vs: block, + fs: block, + uniformTypes: { vmin: 'f32', vmax: 'f32', opacity: 'f32' }, + }; + const [lo, hi] = threshold; + const fs = /* glsl */ `#version 300 es +#define SHADER_NAME ${name}-raster-fragment +precision highp float; +uniform sampler2D u_${name}; +in vec2 v_uv; +out vec4 fragColor; +${rampGlsl} +void main() { + float value = mix(scalar.vmin, scalar.vmax, texture(u_${name}, v_uv).r); + float a = smoothstep(${lo.toFixed(1)}, ${hi.toFixed(1)}, value) * scalar.opacity; + if (a <= 0.0) discard; + fragColor = vec4(${rampFn}(value), a); +}`; + const mesh = equirectGridMesh(90, 140); + const model = new Model(this.context.device, { + id: `${this.props.id}-mesh`, + vs: EQUIRECT_RASTER_VS, + fs, + modules: [project32, uniforms], + geometry: new Geometry({ + topology: 'triangle-list', + vertexCount: mesh.length / 2, + attributes: { a_lnglat: { size: 2, value: mesh } }, + }), + parameters: { + blend: true, + blendColorSrcFactor: 'src-alpha', + blendColorDstFactor: 'one-minus-src-alpha', + blendAlphaSrcFactor: 'one', + blendAlphaDstFactor: 'one-minus-src-alpha', + depthWriteEnabled: false, + depthCompare: 'always', + }, + disableWarnings: true, + }); + this.setState({ + model, + sampler: `u_${name}`, + uboKey: name, + defaultOpacity, + }); + } + + updateState(params: UpdateParameters): void { + const { props, oldProps } = params; + if (props.image && props.image !== oldProps.image) { + void this._load(props.image); + } + } + + async _load(url: string): Promise { + const tex = await loadEquirectTexture(this.context.device, url).catch( + () => null, + ); + if (!tex || this.props.image !== url) { + tex?.destroy(); + return; + } + this.state.texture?.destroy(); + this.setState({ texture: tex }); + this.setNeedsRedraw(); + } + + draw(): void { + const { model, texture, sampler, uboKey, defaultOpacity } = this.state; + if (!texture) return; + model.setBindings({ [sampler]: texture }); + model.shaderInputs.setProps({ + [uboKey]: { + vmin: this.props.min, + vmax: this.props.max, + opacity: this.props.opacity ?? defaultOpacity, + }, + }); + model.draw(this.context.renderPass); + } + + finalizeState(context: LayerContext): void { + super.finalizeState(context); + this.state.texture?.destroy(); + this.state.model?.destroy(); + } +} + +/** GFS composite-reflectivity precip forecast (dBZ). Clear sky (GFS floors + * no-echo at ~−20 dBZ) and faint returns fade out; only real precip paints. */ +export class RefcRasterLayer extends ScalarRasterLayer { + static layerName = 'RefcRasterLayer'; + static defaultProps = { + image: '', + min: -20, + max: 75, + opacity: 0.65, + } as never; + protected scalarConfig(): ScalarConfig { + return { + name: 'refc', + rampGlsl: REFC_RAMP_GLSL, + rampFn: 'refcRamp', + threshold: [8, 20], + defaultOpacity: 0.65, + }; + } +} + +/** GFS surface-CAPE storm potential (J/kg). Stable / weakly-unstable air (below + * ~250 J/kg) fades out; only air primed for convection paints. */ +export class CapeRasterLayer extends ScalarRasterLayer { + static layerName = 'CapeRasterLayer'; + static defaultProps = { image: '', min: 0, max: 5000, opacity: 0.5 } as never; + protected scalarConfig(): ScalarConfig { + return { + name: 'cape', + rampGlsl: CAPE_RAMP_GLSL, + rampFn: 'capeRamp', + threshold: [250, 800], + defaultOpacity: 0.5, + }; + } +}