diff --git a/src/PathTracedVolume.ts b/src/PathTracedVolume.ts index 2cca0de0..898ff0f2 100644 --- a/src/PathTracedVolume.ts +++ b/src/PathTracedVolume.ts @@ -32,6 +32,7 @@ import type { VolumeRenderImpl } from "./VolumeRenderImpl.js"; import { VolumeRenderSettings, SettingsFlags } from "./VolumeRenderSettings.js"; import Channel from "./Channel.js"; import RenderToBuffer from "./RenderToBuffer.js"; +import { VolumeDataUpdater } from "./utils/VolumeDataUpdater.js"; export default class PathTracedVolume implements VolumeRenderImpl { private settings: VolumeRenderSettings; @@ -40,6 +41,7 @@ export default class PathTracedVolume implements VolumeRenderImpl { private viewChannels: number[]; // should have 4 or less elements private volumeTexture: Data3DTexture; + private volumeDataUpdater: VolumeDataUpdater; private cameraIsMoving: boolean; private sampleCounter: number; @@ -81,6 +83,7 @@ export default class PathTracedVolume implements VolumeRenderImpl { this.volumeTexture.generateMipmaps = false; this.volumeTexture.needsUpdate = true; + this.volumeDataUpdater = new VolumeDataUpdater(); // create Lut textures // empty array @@ -258,6 +261,9 @@ export default class PathTracedVolume implements VolumeRenderImpl { // Update channel and alpha mask if they have changed this.updateVolumeData4(); } + if (dirtyFlags & SettingsFlags.MASK_DATA) { + this.updateVolumeData4(); + } if (dirtyFlags & SettingsFlags.VIEW) { this.pathTracingUniforms.gCamera.value.mIsOrtho = this.settings.isOrtho ? 1 : 0; } @@ -436,50 +442,13 @@ export default class PathTracedVolume implements VolumeRenderImpl { } updateVolumeData4(): void { - const { x: sx, y: sy, z: sz } = this.volume.imageInfo.subregionSize; - - const data = new Uint8Array(sx * sy * sz * 4); - data.fill(0); - - for (let i = 0; i < 4; ++i) { - const ch = this.viewChannels[i]; - if (ch === -1) { - continue; - } - - const volumeChannel = this.volume.getChannel(ch); - for (let iz = 0; iz < sz; ++iz) { - for (let iy = 0; iy < sy; ++iy) { - for (let ix = 0; ix < sx; ++ix) { - // TODO expand to 16-bpp raw intensities? - data[i + ix * 4 + iy * 4 * sx + iz * 4 * sx * sy] = - 255 * volumeChannel.normalizeRaw(volumeChannel.getIntensity(ix, iy, iz)); - } - } - } - if (this.settings.maskChannelIndex !== -1 && this.settings.maskAlpha < 1.0) { - const maskChannel = this.volume.getChannel(this.settings.maskChannelIndex); - // const maskMax = maskChannel.getHistogram().dataMax; - 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) { - // nonbinary masking - // maskVal = maskChannel.getIntensity(ix,iy,iz) * maskAlpha / maskMax; - - // binary masking - maskVal = maskChannel.getIntensity(ix, iy, iz) > 0 ? 1.0 : maskAlpha; - - data[i + ix * 4 + iy * 4 * sx + iz * 4 * sx * sy] *= maskVal; - } - } - } - } - } - // defaults to rgba and unsignedbytetype so dont need to supply format this time. - this.volumeTexture.image.data.set(data); - this.volumeTexture.needsUpdate = true; + this.volumeDataUpdater.update( + this.volume, + this.viewChannels, + this.settings.maskChannelIndex, + this.settings.maskAlpha, + this.volumeTexture + ); } updateLuts(channelColors: FuseChannel[], channelData: Channel[]): void { diff --git a/src/RayMarchedAtlasVolume.ts b/src/RayMarchedAtlasVolume.ts index 4a4d6345..c630564e 100644 --- a/src/RayMarchedAtlasVolume.ts +++ b/src/RayMarchedAtlasVolume.ts @@ -5,24 +5,27 @@ import { BufferAttribute, BufferGeometry, Color, + Data3DTexture, DataTexture, DepthTexture, Group, LineBasicMaterial, LineSegments, + LinearFilter, Material, Matrix4, Mesh, + NearestFilter, OrthographicCamera, PerspectiveCamera, + RGBAFormat, ShaderMaterial, Texture, - Vector2, + UnsignedByteType, Vector3, WebGLRenderer, } from "three"; -import FusedChannelData from "./FusedChannelData.js"; import { rayMarchingVertexShaderSrc, rayMarchingFragmentShaderSrc, @@ -32,8 +35,10 @@ import { Volume } from "./index.js"; import Channel from "./Channel.js"; import type { VolumeRenderImpl } from "./VolumeRenderImpl.js"; -import type { FuseChannel } from "./types.js"; +import { FUSE_DISABLED_RGB_COLOR, type FuseChannel } from "./types.js"; import { VolumeRenderSettings, SettingsFlags } from "./VolumeRenderSettings.js"; +import { LUT_ARRAY_LENGTH } from "./Lut.js"; +import { VolumeDataUpdater } from "./utils/VolumeDataUpdater.js"; const BOUNDING_BOX_DEFAULT_COLOR = new Color(0xffff00); @@ -47,9 +52,13 @@ export default class RayMarchedAtlasVolume implements VolumeRenderImpl { private tickMarksMesh: LineSegments; private geometryTransformNode: Group; private uniforms: ReturnType; - private channelData!: FusedChannelData; private emptyPositionTex: DataTexture; + private volumeTexture: Data3DTexture; + private lutTexture: DataTexture; + private viewChannels: number[]; // should have 4 or less elements + private volumeDataUpdater: VolumeDataUpdater; + /** * Creates a new RayMarchedAtlasVolume. * @param volume The volume that this renderer should render data from. @@ -58,6 +67,7 @@ export default class RayMarchedAtlasVolume implements VolumeRenderImpl { */ constructor(volume: Volume, settings: VolumeRenderSettings = new VolumeRenderSettings(volume)) { this.volume = volume; + this.viewChannels = [-1, -1, -1, -1]; this.uniforms = rayMarchingShaderUniforms(); [this.geometry, this.geometryMesh] = this.createGeometry(this.uniforms); @@ -80,6 +90,28 @@ export default class RayMarchedAtlasVolume implements VolumeRenderImpl { this.emptyPositionTex = new DataTexture(new Uint8Array(Array(16).fill(0)), 2, 2); + // create volume texture (Data3DTexture) + const { x: sx, y: sy, z: sz } = volume.imageInfo.subregionSize; + const data = new Uint8Array(sx * sy * sz * 4).fill(0); + this.volumeTexture = new Data3DTexture(data, sx, sy, sz); + this.volumeTexture.format = RGBAFormat; + this.volumeTexture.type = UnsignedByteType; + this.volumeTexture.minFilter = LinearFilter; + this.volumeTexture.magFilter = LinearFilter; + this.volumeTexture.generateMipmaps = false; + this.volumeTexture.needsUpdate = true; + this.volumeDataUpdater = new VolumeDataUpdater(); + + // create LUT texture (256x4, each row is a channel's LUT) + const lutData = new Uint8Array(LUT_ARRAY_LENGTH * 4).fill(255); + this.lutTexture = new DataTexture(lutData, 256, 4, RGBAFormat, UnsignedByteType); + this.lutTexture.minFilter = LinearFilter; + this.lutTexture.magFilter = LinearFilter; + this.lutTexture.needsUpdate = true; + + this.setUniform("volumeTexture", this.volumeTexture); + this.setUniform("gLutTexture", this.lutTexture); + this.settings = settings; this.updateSettings(settings, SettingsFlags.ALL); // TODO this is doing *more* redundant work! Fix? @@ -97,21 +129,6 @@ export default class RayMarchedAtlasVolume implements VolumeRenderImpl { this.boxHelper.box.set(fullRegionScale.clone().multiplyScalar(-0.5), fullRegionScale.clone().multiplyScalar(0.5)); this.tickMarksMesh.scale.copy(fullRegionScale); this.settings && this.updateSettings(this.settings, SettingsFlags.ROI); - - // Set atlas dimension uniforms - const { atlasTileDims, subregionSize } = this.volume.imageInfo; - const atlasSize = new Vector2(subregionSize.x, subregionSize.y).multiply(atlasTileDims); - - this.setUniform("ATLAS_DIMS", atlasTileDims); - - this.setUniform("textureRes", atlasSize); - this.setUniform("SLICES", this.volume.imageInfo.volumeSize.z); - - // (re)create channel data - if (!this.channelData || this.channelData.width !== atlasSize.x || this.channelData.height !== atlasSize.y) { - this.channelData?.cleanup(); - this.channelData = new FusedChannelData(atlasSize.x, atlasSize.y); - } } public viewpointMoved(): void { @@ -193,18 +210,21 @@ export default class RayMarchedAtlasVolume implements VolumeRenderImpl { } 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); } if (dirtyFlags & SettingsFlags.MASK_ALPHA) { this.setUniform("maskAlpha", this.settings.maskChannelIndex < 0 ? 1.0 : this.settings.maskAlpha); + // Re-update volume data with new mask settings + this.updateVolumeData4(); } if (dirtyFlags & SettingsFlags.MASK_DATA) { - this.channelData.setChannelAsMask( - this.settings.maskChannelIndex, - this.volume.getChannel(this.settings.maskChannelIndex) - ); + // Re-update volume data with new mask settings + this.updateVolumeData4(); } } @@ -307,7 +327,8 @@ export default class RayMarchedAtlasVolume implements VolumeRenderImpl { this.geometry.dispose(); this.geometryMesh.material.dispose(); - this.channelData.cleanup(); + this.volumeTexture.dispose(); + this.lutTexture.dispose(); } public doRender( @@ -325,9 +346,6 @@ export default class RayMarchedAtlasVolume implements VolumeRenderImpl { this.setUniform("CLIP_NEAR", camera.near); this.setUniform("CLIP_FAR", camera.far); - this.channelData.gpuFuse(renderer); - this.setUniform("textureAtlas", this.channelData.getFusedTexture()); - this.geometryTransformNode.updateMatrixWorld(true); const mvm = new Matrix4(); @@ -355,13 +373,59 @@ export default class RayMarchedAtlasVolume implements VolumeRenderImpl { this.uniforms[name].value = value; } + /** + * Update the 3D volume texture with data from up to 4 active channels. + */ + private updateVolumeData4(): void { + this.volumeDataUpdater.update( + this.volume, + this.viewChannels, + this.settings.maskChannelIndex, + this.settings.maskAlpha, + this.volumeTexture + ); + } + + /** + * Update LUT textures for the active channels. + */ + 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; + } + // channelcolors is array of {rgbColor, lut} and channeldata is volume.channels public updateActiveChannels(channelcolors: FuseChannel[], channeldata: Channel[]): void { - this.channelData.fuse(channelcolors, channeldata); + const ch = [-1, -1, -1, -1]; + let activeChannel = 0; + const NC = this.volume.imageInfo.numChannels; + const maxch = 4; + for (let i = 0; i < NC && activeChannel < maxch; ++i) { + // check that channel is not disabled and is loaded + if (channelcolors[i].rgbColor !== FUSE_DISABLED_RGB_COLOR && channeldata[i].loaded) { + ch[activeChannel] = i; + activeChannel++; + } + } + + const unchanged = ch.every((elem, index) => elem === this.viewChannels[index], this); + if (unchanged) { + // Still update LUTs in case colors changed + this.updateLuts(channelcolors, channeldata); + return; + } + + this.setUniform("gNChannels", activeChannel); - // update to fused texture - this.setUniform("textureAtlas", this.channelData.getFusedTexture()); - this.setUniform("textureAtlasMask", this.channelData.maskTexture); + this.viewChannels = ch; + // update volume data according to channels selected. + this.updateVolumeData4(); + this.updateLuts(channelcolors, channeldata); } public setRenderUpdateListener(_listener?: ((iteration: number) => void) | undefined) { diff --git a/src/VolumeDrawable.ts b/src/VolumeDrawable.ts index 9246b527..2c4beb17 100644 --- a/src/VolumeDrawable.ts +++ b/src/VolumeDrawable.ts @@ -594,6 +594,9 @@ export default class VolumeDrawable { } } + this.volumeRendering.updateActiveChannels(this.fusion, this.volume.channels); + this.volumeRendering.updateSettings(this.settings, SettingsFlags.MASK_DATA); + // let the outside world have a chance this.onChannelDataReadyCallback?.(); } diff --git a/src/constants/shaders/raymarch.frag b/src/constants/shaders/raymarch.frag index a42a05cd..1a870633 100644 --- a/src/constants/shaders/raymarch.frag +++ b/src/constants/shaders/raymarch.frag @@ -1,29 +1,28 @@ #ifdef GL_ES precision highp float; +precision highp sampler3D; #endif #define M_PI 3.14159265358979323846 uniform vec2 iResolution; -uniform vec2 textureRes; uniform float GAMMA_MIN; uniform float GAMMA_MAX; uniform float GAMMA_SCALE; uniform float BRIGHTNESS; uniform float DENSITY; uniform float maskAlpha; -uniform vec2 ATLAS_DIMS; uniform vec3 AABB_CLIP_MIN; uniform float CLIP_NEAR; uniform vec3 AABB_CLIP_MAX; uniform float CLIP_FAR; -uniform sampler2D textureAtlas; -uniform sampler2D textureAtlasMask; +uniform sampler3D volumeTexture; +uniform sampler2D gLutTexture; +uniform int gNChannels; uniform sampler2D textureDepth; uniform int usingPositionTexture; uniform int BREAK_STEPS; -uniform float SLICES; uniform float isOrtho; uniform float orthoThickness; uniform float orthoScale; @@ -51,7 +50,6 @@ float rand(vec2 co) { vec4 luma2Alpha(vec4 color, float vmin, float vmax, float C) { float x = dot(color.rgb, vec3(0.2125, 0.7154, 0.0721)); - // float x = max(color[2], max(color[0],color[1])); float xi = (x - vmin) / (vmax - vmin); xi = clamp(xi, 0.0, 1.0); float y = pow(xi, C); @@ -60,91 +58,40 @@ vec4 luma2Alpha(vec4 color, float vmin, float vmax, float C) { return color; } -vec2 offsetFrontBack(float t) { - int a = int(t); - int ax = int(ATLAS_DIMS.x); - vec2 os = vec2(float(a - (a / ax) * ax), float(a / ax)) / ATLAS_DIMS; - return clamp(os, vec2(0.0), vec2(1.0) - vec2(1.0) / ATLAS_DIMS); +// Transform position to volume texture coordinates, applying flip +vec3 PtoVolumeTex(vec3 pos) { + // pos is in 0..1 range + // if flipVolume = 1, uvw is unchanged. + // if flipVolume = -1, uvw = 1 - uvw + vec3 uvw = (flipVolume * (pos - 0.5) + 0.5); + return uvw; } -vec4 sampleAtlasLinear(sampler2D tex, vec4 pos) { - float bounds = float(pos[0] >= 0.0 && pos[0] <= 1.0 && - pos[1] >= 0.0 && pos[1] <= 1.0 && - pos[2] >= 0.0 && pos[2] <= 1.0); - float nSlices = float(SLICES); - // get location within atlas tile - // TODO: get loc1 which follows ray to next slice along ray direction - // when flipvolume = 1: pos - // when flipvolume = -1: 1-pos - vec2 loc0 = ((pos.xy - 0.5) * flipVolume.xy + 0.5) / ATLAS_DIMS; - - // loc ranges from 0 to 1/ATLAS_DIMS - // shrink loc0 to within one half edge texel - so as not to sample across edges of tiles. - loc0 = vec2(0.5) / textureRes + loc0 * (vec2(1.0) - ATLAS_DIMS / textureRes); - - // interpolate between two slices - float z = (pos.z) * (nSlices - 1.0); - float z0 = floor(z); - float t = z - z0; //mod(z, 1.0); - float z1 = min(z0 + 1.0, nSlices - 1.0); - - // flipped: - if (flipVolume.z == -1.0) { - z0 = nSlices - z0 - 1.0; - z1 = nSlices - z1 - 1.0; - t = 1.0 - t; +// Sample the 3D volume texture and compute color from LUT +// Returns RGBA color after applying per-channel LUTs and combining channels +// The output has premultiplied alpha to match the old fused texture format +vec4 sampleVolume(vec3 pos) { + float bounds = float(pos.x >= 0.0 && pos.x <= 1.0 && + pos.y >= 0.0 && pos.y <= 1.0 && + pos.z >= 0.0 && pos.z <= 1.0); + + vec3 uvw = PtoVolumeTex(pos); + vec4 intensity = texture(volumeTexture, uvw); + + // Accumulate color from all active channels using their LUTs + // Output is premultiplied alpha (rgb * alpha, alpha) like the old fused texture + vec4 color = vec4(0.0); + for (int i = 0; i < min(gNChannels, 4); ++i) { + float channelIntensity = intensity[i]; + // Sample the LUT for this channel (LUT texture is 256x4, each row is a channel) + vec4 lutColor = texture2D(gLutTexture, vec2(channelIntensity, (0.5 + float(i)) / 4.0)); + // Premultiply alpha: rgb * alpha + vec4 premultiplied = vec4(lutColor.rgb * lutColor.a, lutColor.a); + // Use max blending to combine channels (similar to the atlas fuse approach) + color = max(color, premultiplied); } - - // get slice offsets in texture atlas - vec2 o0 = offsetFrontBack(z0) + loc0; - vec2 o1 = offsetFrontBack(z1) + loc0; - - vec4 slice0Color = texture2D(tex, o0); - vec4 slice1Color = texture2D(tex, o1); - // NOTE we could premultiply the mask in the fuse function, - // but that is slower to update the maskAlpha value than here in the shader. - // it is a memory vs perf tradeoff. Do users really need to update the maskAlpha at realtime speed? - float slice0Mask = texture2D(textureAtlasMask, o0).x; - float slice1Mask = texture2D(textureAtlasMask, o1).x; - // or use max for conservative 0 or 1 masking? - float maskVal = mix(slice0Mask, slice1Mask, t); - // take mask from 0..1 to alpha..1 - maskVal = mix(maskVal, 1.0, maskAlpha); - vec4 retval = mix(slice0Color, slice1Color, t); - // only mask the rgb, not the alpha(?) - retval.rgb *= maskVal; - return bounds * retval; -} - -vec4 sampleAtlasNearest(sampler2D tex, vec4 pos) { - float bounds = float(pos[0] >= 0.0 && pos[0] <= 1.0 && - pos[1] >= 0.0 && pos[1] <= 1.0 && - pos[2] >= 0.0 && pos[2] <= 1.0); - float nSlices = float(SLICES); - - vec2 loc0 = ((pos.xy - 0.5) * flipVolume.xy + 0.5) / ATLAS_DIMS; - - // No interpolation - sample just one slice at a pixel center. - // Ideally this would be accomplished in part by switching this texture to linear - // filtering, but three makes this difficult to do through a WebGLRenderTarget. - loc0 = floor(loc0 * textureRes) / textureRes; - loc0 += vec2(0.5) / textureRes; - - float z = min(floor(pos.z * nSlices), nSlices - 1.0); - - if (flipVolume.z == -1.0) { - z = nSlices - z - 1.0; - } - - vec2 o = offsetFrontBack(z) + loc0; - vec4 voxelColor = texture2D(tex, o); - - // Apply mask - float voxelMask = texture2D(textureAtlasMask, o).x; - voxelMask = mix(voxelMask, 1.0, maskAlpha); - voxelColor.rgb *= voxelMask; - - return bounds * voxelColor; + + return bounds * color; } bool intersectBox( @@ -191,8 +138,7 @@ vec4 integrateVolume( float tnear, float tfar, float clipNear, - float clipFar, - sampler2D textureAtlas + float clipFar ) { vec4 C = vec4(0.0); // march along ray from front to back, accumulating color @@ -203,9 +149,7 @@ vec4 integrateVolume( 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 float tstep = invstep * orthoThickness; float tfarsurf = r * tstep; @@ -226,7 +170,7 @@ vec4 integrateVolume( // AABB clip is independent of this and is only used to determine tnear and tfar. pos.xyz = (pos.xyz - (-0.5)) / ((0.5) - (-0.5)); //0.5 * (pos + 1.0); // map position from [boxMin, boxMax] to [0, 1] coordinates - vec4 col = interpolationEnabled ? sampleAtlasLinear(textureAtlas, pos) : sampleAtlasNearest(textureAtlas, pos); + vec4 col = sampleVolume(pos.xyz); if (maxProject != 0) { col.xyz *= BRIGHTNESS; @@ -322,7 +266,7 @@ void main() { } //tnear and tfar are intersections of box - vec4 C = integrateVolume(vec4(eyeRay_o, 1.0), vec4(eyeRay_d, 0.0), tnear, tfar, clipNear, clipFar, textureAtlas); + vec4 C = integrateVolume(vec4(eyeRay_o, 1.0), vec4(eyeRay_d, 0.0), tnear, tfar, clipNear, clipFar); C = clamp(C, 0.0, 1.0); gl_FragColor = C; diff --git a/src/constants/volumeRayMarchShader.ts b/src/constants/volumeRayMarchShader.ts index 1dfde40e..9741f146 100644 --- a/src/constants/volumeRayMarchShader.ts +++ b/src/constants/volumeRayMarchShader.ts @@ -47,14 +47,6 @@ export const rayMarchingShaderUniforms = () => { type: "i", value: 128, }, - ATLAS_DIMS: { - type: "v2", - value: new Vector2(6, 6), - }, - SLICES: { - type: "f", - value: 50, - }, isOrtho: { type: "f", value: 0.0, @@ -83,14 +75,18 @@ export const rayMarchingShaderUniforms = () => { type: "m4", value: new Matrix4(), }, - textureAtlas: { + volumeTexture: { type: "t", value: new Texture(), }, - textureAtlasMask: { + gLutTexture: { type: "t", value: new Texture(), }, + gNChannels: { + type: "i", + value: 0, + }, textureDepth: { type: "t", value: new Texture(), @@ -115,9 +111,5 @@ export const rayMarchingShaderUniforms = () => { type: "v3", value: new Vector3(1.0, 1.0, 1.0), }, - textureRes: { - type: "v2", - value: new Vector2(1.0, 1.0), - }, }; }; diff --git a/src/utils/VolumeDataUpdater.ts b/src/utils/VolumeDataUpdater.ts new file mode 100644 index 00000000..4ad53958 --- /dev/null +++ b/src/utils/VolumeDataUpdater.ts @@ -0,0 +1,63 @@ +import type { Data3DTexture } from "three"; + +import type Volume from "../Volume.js"; + +export class VolumeDataUpdater { + private scratch?: Uint8Array; + + public update( + volume: Volume, + viewChannels: number[], + maskChannelIndex: number, + maskAlpha: number, + volumeTexture: Data3DTexture + ): void { + const { x: sx, y: sy, z: sz } = volume.imageInfo.subregionSize; + const dataSize = sx * sy * sz * 4; + + if (!this.scratch || this.scratch.length !== dataSize) { + const existing = volumeTexture.image.data as Uint8Array | undefined; + if (existing && existing.length === dataSize) { + this.scratch = existing; + } else { + this.scratch = new Uint8Array(dataSize); + volumeTexture.image.data = this.scratch; + } + } + + const data = this.scratch; + data.fill(0); + + 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; + } + } + } + } + } + + volumeTexture.needsUpdate = true; + } +}