convert raymarch to use 3d texture instead of atlas.#370
Conversation
There was a problem hiding this comment.
Pull request overview
This PR switches the raymarch rendering path from a 2D texture atlas to a shared-style 3D volume texture + per-channel LUT texture (similar to the pathtrace renderer), aiming to simplify representation and enable larger volumes.
Changes:
- Removed atlas-specific uniforms and sampling logic; introduced
sampler3D volumeTexturesampling in the raymarch fragment shader. - Added a 3D
Data3DTextureupload path and a combined 256×4 LUTDataTexturefor up to 4 active channels inRayMarchedAtlasVolume. - Updated active-channel selection to populate the 3D texture + LUTs instead of GPU-fusing an atlas each frame.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/constants/volumeRayMarchShader.ts |
Replaces atlas-related uniforms with volumeTexture, gLutTexture, and gNChannels. |
src/constants/shaders/raymarch.frag |
Replaces atlas sampling with 3D texture sampling + LUT-based color reconstruction. |
src/RayMarchedAtlasVolume.ts |
Removes FusedChannelData atlas pipeline; adds 3D volume texture + LUT texture creation and CPU-side packing. |
Comments suppressed due to low confidence (1)
src/constants/shaders/raymarch.frag:32
maskAlphaandinterpolationEnabledare still declared as uniforms but are no longer used anywhere in this shader after the move tosampler3Dsampling. Consider removing them from the shader and fromrayMarchingShaderUniforms()(and stop settingmaskAlphainRayMarchedAtlasVolume.updateSettings) to avoid confusion and make it clearer which controls are still shader-driven vs baked into the uploaded 3D texture.
uniform vec2 iResolution;
uniform float GAMMA_MIN;
uniform float GAMMA_MAX;
uniform float GAMMA_SCALE;
uniform float BRIGHTNESS;
uniform float DENSITY;
uniform float maskAlpha;
uniform vec3 AABB_CLIP_MIN;
uniform float CLIP_NEAR;
uniform vec3 AABB_CLIP_MAX;
uniform float CLIP_FAR;
uniform sampler3D volumeTexture;
uniform sampler2D gLutTexture;
uniform int gNChannels;
uniform sampler2D textureDepth;
uniform int usingPositionTexture;
uniform int BREAK_STEPS;
uniform float isOrtho;
uniform float orthoThickness;
uniform float orthoScale;
uniform int maxProject;
uniform bool interpolationEnabled;
uniform vec3 flipVolume;
uniform vec3 volumeScale;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -325,9 +344,6 @@ export default class RayMarchedAtlasVolume implements VolumeRenderImpl { | |||
| this.setUniform("CLIP_NEAR", camera.near); | |||
| this.setUniform("CLIP_FAR", camera.far); | |||
|
|
|||
There was a problem hiding this comment.
Raymarch now relies on Data3DTexture/sampler3D, which requires WebGL2. This renderer currently doesn’t guard against running under the WebGL1 fallback path (see ThreeJsPanel’s non-WebGL2 branch), so in WebGL1 this will fail shader compilation / rendering. Please add an explicit WebGL2 capability check (e.g., renderer.capabilities.isWebGL2) and either (a) fall back to the old atlas path or (b) disable raymarch with a clear error message when WebGL2 is unavailable.
| if (this.settings.maskChannelIndex !== -1 && this.settings.maskAlpha < 1.0) { | ||
| const maskChannel = this.volume.getChannel(this.settings.maskChannelIndex); | ||
| let maskVal = 1.0; | ||
| const maskAlpha = this.settings.maskAlpha; | ||
| for (let iz = 0; iz < sz; ++iz) { | ||
| for (let iy = 0; iy < sy; ++iy) { | ||
| for (let ix = 0; ix < sx; ++ix) { | ||
| // binary masking | ||
| maskVal = maskChannel.getIntensity(ix, iy, iz) > 0 ? 1.0 : maskAlpha; | ||
| data[i + ix * 4 + iy * 4 * sx + iz * 4 * sx * sy] *= maskVal; | ||
| } |
There was a problem hiding this comment.
Masking is currently applied by scaling the per-voxel intensity before LUT lookup (data[...] *= maskVal). That changes rendering semantics vs masking the final premultiplied color (it will affect LUT-driven alpha and color, not just RGB), and it also applies the same mask volume up to 4 times (once per channel). If the intended behavior is to match the previous “mask RGB only” approach, consider keeping intensities unchanged and applying the mask factor after LUT evaluation (or uploading a separate mask 3D texture / reserving one channel for mask). Also, compute the mask once and reuse it across channels to avoid the repeated full-volume pass.
There was a problem hiding this comment.
My first thought is that this is more of an efficiency than a correctness issue?
| // special-casing the single slice to remove the random ray dither. | ||
| // this removes a Moire pattern visible in single slice images, which we want to view as 2D images as best we can. | ||
| float r = (SLICES == 1.0) ? 0.0 : rand(eye_d.xy); | ||
| float r = rand(eye_d.xy); |
There was a problem hiding this comment.
The ray dither is now always enabled (float r = rand(eye_d.xy);). Previously there was a special-case to disable dithering for single-slice volumes to avoid Moiré artifacts. If single-slice datasets are still supported, consider restoring that behavior by checking the 3D texture depth (e.g., textureSize(volumeTexture, 0).z) and forcing r = 0.0 when depth is 1.
| float r = rand(eye_d.xy); | |
| // Disable ray dithering for single-slice volumes to avoid Moiré artifacts. | |
| int volumeDepth = textureSize(volumeTexture, 0).z; | |
| float r = (volumeDepth == 1) ? 0.0 : rand(eye_d.xy); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for (let i = 0; i < 4; ++i) { | ||
| const ch = viewChannels[i]; | ||
| if (ch === -1) { | ||
| continue; | ||
| } | ||
|
|
||
| const volumeChannel = volume.getChannel(ch); | ||
| for (let iz = 0; iz < sz; ++iz) { | ||
| for (let iy = 0; iy < sy; ++iy) { | ||
| for (let ix = 0; ix < sx; ++ix) { | ||
| data[i + ix * 4 + iy * 4 * sx + iz * 4 * sx * sy] = | ||
| 255 * volumeChannel.normalizeRaw(volumeChannel.getIntensity(ix, iy, iz)); | ||
| } | ||
| } | ||
| } | ||
| if (maskChannelIndex !== -1 && maskAlpha < 1.0) { | ||
| const maskChannel = volume.getChannel(maskChannelIndex); | ||
| let maskVal = 1.0; | ||
| for (let iz = 0; iz < sz; ++iz) { | ||
| for (let iy = 0; iy < sy; ++iy) { | ||
| for (let ix = 0; ix < sx; ++ix) { | ||
| // binary masking | ||
| maskVal = maskChannel.getIntensity(ix, iy, iz) > 0 ? 1.0 : maskAlpha; | ||
| data[i + ix * 4 + iy * 4 * sx + iz * 4 * sx * sy] *= maskVal; | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
VolumeDataUpdater.update() traverses the full 3D volume twice per active channel when masking is enabled (once to write intensities, then again to apply the mask). For large volumes and multiple channels this is a significant CPU cost. Consider folding mask application into the intensity-write loop (or precomputing a single mask volume once and applying it across channels) to avoid the extra full-volume pass per channel.
| float scaledSteps = float(BREAK_STEPS) * length((eye_d.xyz / volumeScale)); | ||
| float csteps = clamp(float(scaledSteps), 1.0, float(maxSteps)); | ||
| float invstep = (tfar - tnear) / csteps; | ||
| // special-casing the single slice to remove the random ray dither. | ||
| // this removes a Moire pattern visible in single slice images, which we want to view as 2D images as best we can. | ||
| float r = (SLICES == 1.0) ? 0.0 : rand(eye_d.xy); | ||
| float r = rand(eye_d.xy); | ||
| // if ortho and clipped, make step size smaller so we still get same number of steps |
There was a problem hiding this comment.
The previous shader disabled random ray dithering for single-slice volumes to avoid a visible Moiré pattern; that special-case was removed with the atlas code. With a 3D texture, volumes can still have depth=1, so this will reintroduce that artifact. Consider reintroducing an equivalent condition (e.g., a volumeDepth/isSingleSlice uniform set from subregionSize.z) to force r = 0.0 when depth is 1.
| } | ||
|
|
||
| if (dirtyFlags & SettingsFlags.MASK_ALPHA) { | ||
| this.setUniform("maskAlpha", this.settings.maskChannelIndex < 0 ? 1.0 : this.settings.maskAlpha); |
There was a problem hiding this comment.
maskAlpha is now applied on the CPU when building volumeTexture, but the shader uniform is still being set here. Since the fragment shader no longer uses maskAlpha, keeping this uniform update is misleading and can be removed (or, if runtime maskAlpha changes are expected without rebuilding the 3D texture, the shader should use the uniform again).
| this.setUniform("maskAlpha", this.settings.maskChannelIndex < 0 ? 1.0 : this.settings.maskAlpha); |
| if (dirtyFlags & SettingsFlags.SAMPLING) { | ||
| this.setUniform("interpolationEnabled", this.settings.useInterpolation); | ||
| this.volumeTexture.minFilter = this.volumeTexture.magFilter = this.settings.useInterpolation | ||
| ? LinearFilter | ||
| : NearestFilter; | ||
| this.volumeTexture.needsUpdate = true; | ||
| this.setUniform("iResolution", this.settings.resolution); | ||
| } |
There was a problem hiding this comment.
Sampling interpolation is now controlled via volumeTexture.minFilter/magFilter, but the shader uniform interpolationEnabled is still declared and included in the uniforms object. Since it’s no longer used in raymarch.frag, consider removing the uniform from both the TS uniforms and GLSL to avoid dead/unreferenced state and reduce confusion.
| private updateLuts(channelColors: FuseChannel[], channelData: Channel[]): void { | ||
| for (let i = 0; i < this.uniforms.gNChannels.value; ++i) { | ||
| const channel = this.viewChannels[i]; | ||
| const combinedLut = channelData[channel].combineLuts(channelColors[channel].rgbColor); | ||
|
|
||
| this.lutTexture.image.data.set(combinedLut, i * LUT_ARRAY_LENGTH); | ||
| } | ||
| this.lutTexture.needsUpdate = true; |
There was a problem hiding this comment.
updateLuts() calls Channel.combineLuts() without providing the optional out buffer, which allocates a new Uint8Array every time LUTs are updated. This can create noticeable GC churn when users adjust colors/LUTs frequently. Consider reusing a preallocated Uint8Array per active slot (and passing it as out) so LUT updates are in-place.
what would it look like if the raymarch shader used the same 3d volume texture that the pathtrace mode uses?
Limitation:
Benefit:
TODO: