@niivue/niivue
Version:
minimal webgl2 nifti image viewer
522 lines (454 loc) • 14.1 kB
text/typescript
/**
* Mesh rendering helper functions for 3D mesh visualization.
* This module provides pure functions for 3D mesh rendering operations.
*
* Related to: 3D mesh rendering, shader selection, fiber rendering, x-ray effects
*/
import { mat4, vec3 } from 'gl-matrix'
import { NVMesh } from '@/nvmesh'
import { Shader } from '@/shader'
// WebGL texture unit constants
const TEXTURE5_MATCAP = 33989 // gl.TEXTURE5
/**
* Represents a shader entry for mesh rendering
* Note: Uses capital 'Name' and 'Frag' to match existing data structure
*/
export interface MeshShaderEntry {
Name: string
Frag: string
shader?: Shader | null
}
/**
* Parameters for determining if a mesh should be rendered
*/
export interface ShouldRenderMeshParams {
visible: boolean
opacity: number
indexCount: number
}
/**
* Determine if a mesh should be rendered based on its properties.
* @param params - Mesh visibility parameters
* @returns Whether the mesh should be rendered
*/
export function shouldRenderMesh(params: ShouldRenderMeshParams): boolean {
const { visible, opacity, indexCount } = params
return visible && opacity > 0.0 && indexCount >= 3
}
/**
* Parameters for selecting mesh shader
*/
export interface SelectMeshShaderParams {
meshShaderIndex: number
meshShaders: MeshShaderEntry[]
pickingMeshShader: Shader | null
mouseDepthPicker: boolean
}
/**
* Select the appropriate shader for mesh rendering.
* @param params - Shader selection parameters
* @returns The shader to use for rendering
*/
export function selectMeshShader(params: SelectMeshShaderParams): Shader | null {
const { meshShaderIndex, meshShaders, pickingMeshShader, mouseDepthPicker } = params
if (mouseDepthPicker) {
return pickingMeshShader
}
return meshShaders[meshShaderIndex]?.shader ?? null
}
/**
* Parameters for calculating mesh alpha/opacity
*/
export interface CalculateMeshAlphaParams {
meshOpacity: number
globalAlpha: number
}
/**
* Calculate the final alpha value for a mesh.
* @param params - Alpha calculation parameters
* @returns The combined alpha value
*/
export function calculateMeshAlpha(params: CalculateMeshAlphaParams): number {
const { meshOpacity, globalAlpha } = params
return meshOpacity * globalAlpha
}
/**
* Parameters for determining if a mesh is a fiber mesh
*/
export interface IsFiberMeshParams {
offsetPt0: number[] | Uint32Array | null | undefined
fiberSides: number
fiberRadius: number
}
/**
* Determine if a mesh is a fiber (line-based) mesh.
* @param params - Mesh fiber properties
* @returns Whether the mesh is a fiber mesh
*/
export function isFiberMesh(params: IsFiberMeshParams): boolean {
const { offsetPt0, fiberSides, fiberRadius } = params
return !!offsetPt0 && (fiberSides < 3 || fiberRadius <= 0)
}
/**
* Parameters for calculating crosscut slice position
*/
export interface CalculateCrosscutSliceParams {
modelMtx: mat4
crosshairMM: vec3 | number[]
is2D: boolean
}
/**
* Calculate the slice position for crosscut shader, handling 2D view occlusion.
* @param params - Crosscut calculation parameters
* @returns The mm coordinates with potential OUT_OF_RANGE values for 2D views
*/
export function calculateCrosscutSlice(params: CalculateCrosscutSliceParams): number[] {
const { modelMtx, crosshairMM, is2D } = params
const OUT_OF_RANGE = 1e9
const mm = [crosshairMM[0], crosshairMM[1], crosshairMM[2]]
if (is2D) {
// Determine which axes to hide based on model matrix orientation
// Check sagittal orientation
if (Math.abs(modelMtx[2]) + Math.abs(modelMtx[4]) + Math.abs(modelMtx[9]) >= 2.95) {
mm[1] = OUT_OF_RANGE
mm[2] = OUT_OF_RANGE
}
// Check coronal orientation
if (Math.abs(modelMtx[0]) + Math.abs(modelMtx[6]) + Math.abs(modelMtx[9]) >= 2.95) {
mm[0] = OUT_OF_RANGE
mm[2] = OUT_OF_RANGE
}
// Check axial orientation
if (Math.abs(modelMtx[0]) + Math.abs(modelMtx[5]) + Math.abs(modelMtx[10]) >= 2.95) {
mm[0] = OUT_OF_RANGE
mm[1] = OUT_OF_RANGE
}
}
return mm
}
/**
* Get mesh thickness in mm, defaulting to 1.0 if invalid.
* @param meshThicknessOn2D - The configured mesh thickness
* @returns Valid mesh thickness value
*/
export function getMeshThickness(meshThicknessOn2D: number | string | undefined): number {
let thickness = Number(meshThicknessOn2D)
if (!Number.isFinite(thickness)) {
thickness = 1.0
}
return thickness
}
/**
* Parameters for configuring GL state for mesh rendering
*/
export interface ConfigureMeshGLStateParams {
gl: WebGL2RenderingContext
isDepthTest: boolean
alpha: number
}
/**
* Configure WebGL state for opaque or transparent mesh rendering.
* @param params - GL state configuration parameters
*/
export function configureMeshGLState(params: ConfigureMeshGLStateParams): void {
const { gl, isDepthTest, alpha } = params
gl.enable(gl.DEPTH_TEST)
if (isDepthTest) {
gl.depthFunc(gl.LEQUAL)
} else {
gl.depthFunc(gl.ALWAYS)
}
gl.cullFace(gl.BACK)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
if (alpha >= 1.0) {
// Opaque
gl.disable(gl.BLEND)
gl.depthMask(true)
} else {
// Transparent
gl.enable(gl.BLEND)
gl.depthMask(false)
}
}
/**
* Parameters for setting up crosscut shader
*/
export interface SetupCrosscutShaderParams {
gl: WebGL2RenderingContext
shader: Shader
modelMtx: mat4
sliceMM: number[]
meshThickness: number
}
/**
* Setup shader uniforms for crosscut rendering mode.
* @param params - Crosscut shader setup parameters
*/
export function setupCrosscutShader(params: SetupCrosscutShaderParams): void {
const { gl, shader, modelMtx, sliceMM, meshThickness } = params
gl.disable(gl.DEPTH_TEST)
gl.disable(gl.CULL_FACE)
gl.uniformMatrix4fv(shader.uniforms.modelMtx, false, modelMtx)
gl.uniform4fv(shader.uniforms.sliceMM, [sliceMM[0], sliceMM[1], sliceMM[2], meshThickness])
}
/**
* Parameters for setting mesh shader uniforms
*/
export interface SetMeshUniformsParams {
gl: WebGL2RenderingContext
shader: Shader
mvpMtx: mat4
normMtx: mat4
opacity: number
}
/**
* Set common shader uniforms for mesh rendering.
* @param params - Uniform setup parameters
*/
export function setMeshUniforms(params: SetMeshUniformsParams): void {
const { gl, shader, mvpMtx, normMtx, opacity } = params
gl.uniformMatrix4fv(shader.uniforms.mvpMtx, false, mvpMtx)
gl.uniformMatrix4fv(shader.uniforms.normMtx, false, normMtx)
gl.uniform1f(shader.uniforms.opacity, opacity)
}
/**
* Parameters for binding matcap texture
*/
export interface BindMatcapTextureParams {
gl: WebGL2RenderingContext
matCapTexture: WebGLTexture | null
}
/**
* Bind the matcap texture for matcap shader rendering.
* @param params - Matcap binding parameters
*/
export function bindMatcapTexture(params: BindMatcapTextureParams): void {
const { gl, matCapTexture } = params
gl.activeTexture(TEXTURE5_MATCAP)
gl.bindTexture(gl.TEXTURE_2D, matCapTexture)
}
/**
* Parameters for drawing a single mesh
*/
export interface DrawSingleMeshParams {
gl: WebGL2RenderingContext
mesh: NVMesh
unusedVAO: WebGLVertexArrayObject
}
/**
* Draw a single mesh using its VAO.
* @param params - Mesh drawing parameters
*/
export function drawSingleMesh(params: DrawSingleMeshParams): void {
const { gl, mesh, unusedVAO } = params
gl.bindVertexArray(mesh.vao)
gl.drawElements(gl.TRIANGLES, mesh.indexCount!, gl.UNSIGNED_INT, 0)
gl.bindVertexArray(unusedVAO)
}
/**
* Parameters for drawing fiber mesh
*/
export interface DrawFiberMeshParams {
gl: WebGL2RenderingContext
mesh: NVMesh
unusedVAO: WebGLVertexArrayObject
}
/**
* Draw a fiber mesh using line strips.
* @param params - Fiber mesh drawing parameters
*/
export function drawFiberMesh(params: DrawFiberMeshParams): void {
const { gl, mesh, unusedVAO } = params
gl.bindVertexArray(mesh.vaoFiber)
gl.drawElements(gl.LINE_STRIP, mesh.indexCount!, gl.UNSIGNED_INT, 0)
gl.bindVertexArray(unusedVAO)
}
/**
* Parameters for X-ray mesh pass
*/
export interface ConfigureXRayPassParams {
gl: WebGL2RenderingContext
}
/**
* Configure GL state for X-ray mesh rendering pass.
* @param params - X-ray configuration parameters
*/
export function configureXRayPass(params: ConfigureXRayPassParams): void {
const { gl } = params
gl.enable(gl.BLEND)
gl.depthMask(false)
gl.depthFunc(gl.ALWAYS)
}
/**
* Reset GL state after X-ray pass.
* @param gl - WebGL context
*/
export function resetAfterXRayPass(gl: WebGL2RenderingContext): void {
gl.depthMask(true)
gl.depthFunc(gl.LEQUAL)
gl.disable(gl.BLEND)
}
/**
* Reset GL state after mesh rendering.
* @param gl - WebGL context
*/
export function resetMeshGLState(gl: WebGL2RenderingContext): void {
gl.depthMask(true)
gl.disable(gl.BLEND)
}
/**
* Parameters for the full mesh 3D rendering pass
*/
export interface DrawMesh3DParams {
gl: WebGL2RenderingContext
meshes: NVMesh[]
isDepthTest: boolean
alpha: number
mvpMatrix: mat4
modelMatrix: mat4
normalMatrix: mat4
is2D: boolean
meshShaders: MeshShaderEntry[]
pickingMeshShader: Shader | null
fiberShader: Shader | null
mouseDepthPicker: boolean
matCapTexture: WebGLTexture | null
unusedVAO: WebGLVertexArrayObject
meshXRay: number
meshThicknessOn2D: number | string | undefined
frac2mm: (pos: number[]) => number[] | vec3 | Float32Array
crosshairPos: number[] | vec3
}
/**
* Render all visible 3D meshes with proper blending, depth, and shader settings.
* @param params - Full mesh rendering parameters
*/
export function drawMesh3D(params: DrawMesh3DParams): void {
const {
gl,
meshes,
isDepthTest,
alpha,
mvpMatrix,
modelMatrix,
normalMatrix,
is2D,
meshShaders,
pickingMeshShader,
fiberShader,
mouseDepthPicker,
matCapTexture,
unusedVAO,
meshXRay,
meshThicknessOn2D,
frac2mm,
crosshairPos
} = params
if (meshes.length < 1) {
return
}
gl.enable(gl.DEPTH_TEST)
// Use inverted depth convention
if (isDepthTest) {
gl.depthFunc(gl.LEQUAL)
} else {
gl.depthFunc(gl.ALWAYS)
}
gl.cullFace(gl.BACK)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
let hasFibers = false
// -----------------------
// Pass 1: Main mesh draw
// -----------------------
for (const mesh of meshes) {
if (!shouldRenderMesh({ visible: mesh.visible, opacity: mesh.opacity, indexCount: mesh.indexCount! })) {
continue
}
const meshAlpha = calculateMeshAlpha({ meshOpacity: mesh.opacity, globalAlpha: alpha })
// Select shader
const shader = selectMeshShader({
meshShaderIndex: mesh.meshShaderIndex,
meshShaders,
pickingMeshShader,
mouseDepthPicker
})
if (!shader) {
continue
}
shader.use(gl)
if (shader.isCrosscut) {
const mm = frac2mm([crosshairPos[0], crosshairPos[1], crosshairPos[2]])
const sliceMM = calculateCrosscutSlice({ modelMtx: modelMatrix, crosshairMM: mm, is2D })
const thickness = getMeshThickness(meshThicknessOn2D)
setupCrosscutShader({ gl, shader, modelMtx: modelMatrix, sliceMM, meshThickness: thickness })
} else {
gl.enable(gl.CULL_FACE)
gl.enable(gl.DEPTH_TEST)
}
// Set uniforms
setMeshUniforms({ gl, shader, mvpMtx: mvpMatrix, normMtx: normalMatrix, opacity: meshAlpha })
// Depth + blending per mesh
if (meshAlpha >= 1.0) {
gl.disable(gl.BLEND)
gl.depthMask(true)
} else {
gl.enable(gl.BLEND)
gl.depthMask(false)
}
// Fiber meshes drawn later
if (isFiberMesh({ offsetPt0: mesh.offsetPt0, fiberSides: mesh.fiberSides, fiberRadius: mesh.fiberRadius })) {
hasFibers = true
continue
}
if (shader.isMatcap) {
bindMatcapTexture({ gl, matCapTexture })
}
// Draw mesh
drawSingleMesh({ gl, mesh, unusedVAO })
}
gl.enable(gl.CULL_FACE)
// -----------------------
// Pass 2: X-Ray Mesh
// -----------------------
if (meshXRay > 0.0 && !hasFibers) {
configureXRayPass({ gl })
for (const mesh of meshes) {
if (!mesh.visible || mesh.indexCount! < 3) {
continue
}
const shader = meshShaders[mesh.meshShaderIndex]?.shader
if (!shader) {
continue
}
shader.use(gl)
setMeshUniforms({
gl,
shader,
mvpMtx: mvpMatrix,
normMtx: normalMatrix,
opacity: mesh.opacity * alpha * meshXRay
})
drawSingleMesh({ gl, mesh, unusedVAO })
}
resetAfterXRayPass(gl)
}
// -----------------------
// Pass 3: Fibers
// -----------------------
if (hasFibers && fiberShader) {
fiberShader.use(gl)
gl.uniformMatrix4fv(fiberShader.uniforms.mvpMtx, false, mvpMatrix)
gl.uniform1f(fiberShader.uniforms.opacity, alpha)
for (const mesh of meshes) {
if (!mesh.offsetPt0) {
continue
}
if (mesh.fiberSides >= 3 && mesh.fiberRadius > 0) {
continue // cylinders already drawn
}
drawFiberMesh({ gl, mesh, unusedVAO })
}
}
// Restore defaults
resetMeshGLState(gl)
}