@niivue/niivue
Version:
minimal webgl2 nifti image viewer
359 lines (340 loc) • 15.6 kB
text/typescript
/**
* WebGL utility functions for context initialization and texture management.
* This module provides pure functions for WebGL operations that can be reused
* throughout the library without class instantiation overhead.
*/
// Texture unit constants
const TEXTURE3_FONT = 33987
const TEXTURE4_THUMBNAIL = 33988
const TEXTURE5_MATCAP = 33989
/**
* Initialize WebGL2 context and detect GPU capabilities
* @param canvas - The canvas element to attach to
* @param isAntiAlias - Whether to enable anti-aliasing
* @returns Object containing gl context and GPU limits
*/
export function initGL(canvas: HTMLCanvasElement, isAntiAlias: boolean): { gl: WebGL2RenderingContext; max2D: number; max3D: number } {
const gl = canvas.getContext('webgl2', {
alpha: true,
antialias: isAntiAlias
})
if (!gl) {
throw new Error('Unable to initialize WebGL2. Your browser may not support it.')
}
return {
gl,
max2D: gl.getParameter(gl.MAX_TEXTURE_SIZE) as number,
max3D: gl.getParameter(gl.MAX_3D_TEXTURE_SIZE) as number
}
}
/**
* Creates a 3D 1-component uint8 texture on the GPU with given dimensions.
* @param gl - WebGL2 rendering context
* @param texID - Existing texture to delete (null for new texture)
* @param activeID - Texture unit to activate
* @param dims - Dimensions array [0, width, height, depth]
* @param isInit - Whether to initialize with zeros
* @returns The created WebGL texture
*/
export function r8Tex(gl: WebGL2RenderingContext, texID: WebGLTexture | null, activeID: number, dims: number[], isInit = false): WebGLTexture | null {
if (texID) {
gl.deleteTexture(texID)
}
texID = gl.createTexture()
gl.activeTexture(activeID)
gl.bindTexture(gl.TEXTURE_3D, texID)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texStorage3D(gl.TEXTURE_3D, 1, gl.R8, dims[1], dims[2], dims[3])
if (isInit) {
const img8 = new Uint8Array(dims[1] * dims[2] * dims[3])
gl.texSubImage3D(gl.TEXTURE_3D, 0, 0, 0, 0, dims[1], dims[2], dims[3], gl.RED, gl.UNSIGNED_BYTE, img8)
}
return texID
}
/**
* Creates a 3D 1-component uint8 texture with LINEAR filtering.
* Used for smooth drawing blur textures that need interpolation.
* @param gl - WebGL2 rendering context
* @param texID - Existing texture to delete (null for new texture)
* @param activeID - Texture unit to activate
* @param dims - Dimensions array [0, width, height, depth]
* @returns The created WebGL texture
*/
export function r8TexLinear(gl: WebGL2RenderingContext, texID: WebGLTexture | null, activeID: number, dims: number[]): WebGLTexture | null {
if (texID) {
gl.deleteTexture(texID)
}
texID = gl.createTexture()
gl.activeTexture(activeID)
gl.bindTexture(gl.TEXTURE_3D, texID)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texStorage3D(gl.TEXTURE_3D, 1, gl.R8, dims[1], dims[2], dims[3])
return texID
}
/**
* Creates or updates a 1-component 16-bit signed integer 3D texture on the GPU.
* @param gl - WebGL2 rendering context
* @param texID - Existing texture to delete (null for new texture)
* @param activeID - Texture unit to activate
* @param dims - Dimensions array [0, width, height, depth]
* @param img16 - 16-bit signed integer data
* @returns The created WebGL texture
*/
export function r16Tex(gl: WebGL2RenderingContext, texID: WebGLTexture | null, activeID: number, dims: number[], img16: Int16Array): WebGLTexture {
if (texID) {
gl.deleteTexture(texID)
}
texID = gl.createTexture()!
gl.activeTexture(activeID)
gl.bindTexture(gl.TEXTURE_3D, texID)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texStorage3D(gl.TEXTURE_3D, 1, gl.R16I, dims[1], dims[2], dims[3])
const nv = dims[1] * dims[2] * dims[3]
if (img16.length !== nv) {
img16 = new Int16Array(nv)
}
gl.texSubImage3D(gl.TEXTURE_3D, 0, 0, 0, 0, dims[1], dims[2], dims[3], gl.RED_INTEGER, gl.SHORT, img16)
return texID
}
/**
* Creates a 3D 1-component float16 texture on the GPU with LINEAR filtering.
* Used for smoothed drawing surfaces.
* @param gl - WebGL2 rendering context
* @param texID - Existing texture to delete (null for new texture)
* @param activeID - Texture unit to activate
* @param dims - Dimensions array [0, width, height, depth]
* @param data - Float32Array data to upload (will be stored as R16F)
* @returns The created WebGL texture
*/
export function r16fTex(gl: WebGL2RenderingContext, texID: WebGLTexture | null, activeID: number, dims: number[], data: Float32Array): WebGLTexture | null {
if (texID) {
gl.deleteTexture(texID)
}
texID = gl.createTexture()
gl.activeTexture(activeID)
gl.bindTexture(gl.TEXTURE_3D, texID)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texStorage3D(gl.TEXTURE_3D, 1, gl.R16F, dims[1], dims[2], dims[3])
gl.texSubImage3D(gl.TEXTURE_3D, 0, 0, 0, 0, dims[1], dims[2], dims[3], gl.RED, gl.FLOAT, data)
return texID
}
/**
* Creates a 2D 4-component (RGBA) uint8 texture on the GPU with optional vertical flip.
* @param gl - WebGL2 rendering context
* @param texID - Existing texture to delete (null for new texture)
* @param activeID - Texture unit to activate
* @param dims - Dimensions array [0, width, height]
* @param data - Optional RGBA data
* @param isFlipVertical - Whether to flip texture vertically
* @returns The created WebGL texture
*/
export function rgbaTex2D(gl: WebGL2RenderingContext, texID: WebGLTexture | null, activeID: number, dims: number[], data: Uint8Array | null = null, isFlipVertical = true): WebGLTexture | null {
if (texID) {
gl.deleteTexture(texID)
}
texID = gl.createTexture()
gl.activeTexture(activeID)
gl.bindTexture(gl.TEXTURE_2D, texID)
// Set texture parameters
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
// Allocate storage for the 2D texture
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, dims[1], dims[2])
if (data) {
let drawData = data
const width = dims[1]
const height = dims[2]
if (isFlipVertical) {
drawData = new Uint8Array(data.length)
const rowSize = width * 4 // RGBA has 4 bytes per pixel
for (let y = 0; y < height; y++) {
const srcStart = y * rowSize
const destStart = (height - 1 - y) * rowSize
drawData.set(data.subarray(srcStart, srcStart + rowSize), destStart)
}
}
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, drawData)
}
return texID
}
/**
* Creates a 3D 4-component (RGBA) uint8 texture on the GPU.
* @param gl - WebGL2 rendering context
* @param texID - Existing texture to delete (null for new texture)
* @param activeID - Texture unit to activate
* @param dims - Dimensions array [0, width, height, depth]
* @param isInit - Whether to initialize with zeros
* @returns The created WebGL texture
*/
export function rgbaTex(gl: WebGL2RenderingContext, texID: WebGLTexture | null, activeID: number, dims: number[], isInit = false): WebGLTexture | null {
if (texID) {
gl.deleteTexture(texID)
}
texID = gl.createTexture()
gl.activeTexture(activeID)
gl.bindTexture(gl.TEXTURE_3D, texID)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texStorage3D(gl.TEXTURE_3D, 1, gl.RGBA8, dims[1], dims[2], dims[3])
if (isInit) {
const img8 = new Uint8Array(dims[1] * dims[2] * dims[3] * 4)
gl.texSubImage3D(gl.TEXTURE_3D, 0, 0, 0, 0, dims[1], dims[2], dims[3], gl.RGBA, gl.UNSIGNED_BYTE, img8)
}
return texID
}
/**
* Create or recreate a 3D RGBA16UI texture on the GPU with given dimensions.
* @param gl - WebGL2 rendering context
* @param texID - Existing texture to delete (null for new texture)
* @param activeID - Texture unit to activate
* @param dims - Dimensions array [0, width, height, depth]
* @param isInit - Whether to initialize with zeros
* @returns The created WebGL texture
*/
export function rgba16Tex(gl: WebGL2RenderingContext, texID: WebGLTexture | null, activeID: number, dims: number[], isInit = false): WebGLTexture | null {
if (texID) {
gl.deleteTexture(texID)
}
texID = gl.createTexture()
gl.activeTexture(activeID)
gl.bindTexture(gl.TEXTURE_3D, texID)
// Note: cannot be gl.LINEAR for integer textures
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 2)
gl.pixelStorei(gl.PACK_ALIGNMENT, 2)
gl.texStorage3D(gl.TEXTURE_3D, 1, gl.RGBA16UI, dims[1], dims[2], dims[3])
if (isInit) {
const img16 = new Uint16Array(dims[1] * dims[2] * dims[3] * 4)
gl.texSubImage3D(gl.TEXTURE_3D, 0, 0, 0, 0, dims[1], dims[2], dims[3], gl.RGBA_INTEGER, gl.UNSIGNED_SHORT, img16)
}
return texID
}
/**
* Remove cross-origin attribute from image if its URL is not from the same origin as the current page.
* @param img - The image element
* @param url - The image URL
*/
export function requestCORSIfNotSameOrigin(img: HTMLImageElement, url: string): void {
if (new URL(url, window.location.href).origin !== window.location.origin) {
img.crossOrigin = ''
}
}
/**
* Loads a PNG image from a URL and creates a 4-component (RGBA) uint8 WebGL texture.
* @param gl - WebGL2 rendering context
* @param pngUrl - URL of the PNG image
* @param textureNum - Texture unit number (3=font, 4=thumbnail, 5=matcap)
* @param fontShader - Font shader (required for textureNum 3)
* @param bmpShader - Bitmap shader (required for textureNum 4)
* @param fontTexture - Current font texture reference (will be deleted if not null)
* @param bmpTexture - Current bitmap texture reference (will be deleted if not null)
* @param matCapTexture - Current matcap texture reference (will be deleted if not null)
* @param onBmpTextureLoaded - Callback when bitmap texture loaded with width/height ratio
* @returns Promise resolving to the created texture
*/
export async function loadPngAsTexture(
gl: WebGL2RenderingContext,
pngUrl: string,
textureNum: number,
fontShader: { use: (gl: WebGL2RenderingContext) => void; uniforms: Record<string, WebGLUniformLocation> } | null,
bmpShader: { use: (gl: WebGL2RenderingContext) => void; uniforms: Record<string, WebGLUniformLocation> } | null,
fontTexture: WebGLTexture | null,
bmpTexture: WebGLTexture | null,
matCapTexture: WebGLTexture | null,
onBmpTextureLoaded?: (widthHeightRatio: number) => void
): Promise<WebGLTexture | null> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = (): void => {
if (!bmpShader) {
return
}
let pngTexture: WebGLTexture | null
if (textureNum === 4) {
// Delete old texture if it exists
if (bmpTexture !== null) {
gl.deleteTexture(bmpTexture)
}
// Create new texture
pngTexture = gl.createTexture()
const bmpTextureWH = img.width / img.height
gl.activeTexture(TEXTURE4_THUMBNAIL)
bmpShader.use(gl)
gl.uniform1i(bmpShader.uniforms.bmpTexture, 4)
if (onBmpTextureLoaded) {
onBmpTextureLoaded(bmpTextureWH)
}
} else if (textureNum === 5) {
// Delete old texture if it exists
if (matCapTexture !== null) {
gl.deleteTexture(matCapTexture)
}
// Create new texture
pngTexture = gl.createTexture()
gl.activeTexture(TEXTURE5_MATCAP)
} else {
// textureNum === 3 (font)
if (!fontShader) {
reject(new Error('Font shader required for texture unit 3'))
return
}
// Delete old texture if it exists
if (fontTexture !== null) {
gl.deleteTexture(fontTexture)
}
// Create new texture
pngTexture = gl.createTexture()
fontShader.use(gl)
gl.activeTexture(TEXTURE3_FONT)
gl.uniform1i(fontShader.uniforms.fontTexture, 3)
}
gl.bindTexture(gl.TEXTURE_2D, pngTexture)
// Set the parameters so we can render any size image.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
// Upload the image into the texture.
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)
resolve(pngTexture)
// PR1567
// if (textureNum !== 4 && onDrawScene) {
// onDrawScene()
// }
}
img.onerror = reject
requestCORSIfNotSameOrigin(img, pngUrl)
img.src = pngUrl
})
}