From 55533f68ea4419d73f22666c96d6a2d8a392f00f Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 20 Aug 2026 14:14:31 +0200 Subject: [PATCH 01/33] feat: add line clipping at the ends This is to allow to avoid intersecting points at the ends of the lines --- src/webgl/lines.ts | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src/webgl/lines.ts b/src/webgl/lines.ts index 66d3572f25..09313fde44 100644 --- a/src/webgl/lines.ts +++ b/src/webgl/lines.ts @@ -28,7 +28,15 @@ import { glsl_clipLineToDepthRange } from "#src/webgl/shader_lib.js"; export const VERTICES_PER_LINE = VERTICES_PER_QUAD; -export function defineLineShader(builder: ShaderBuilder, rounded = false) { +/** + @param rounded adds a float borderWidth param to emitLine + @param endpointClipping adds a float endpointClipping param to emitLine + */ +export function defineLineShader( + builder: ShaderBuilder, + rounded = false, + endpointClipping = false, +) { builder.addVertexCode(glsl_getQuadVertexPosition); // x: 1 / viewportWidth // y: 1 / viewportHeight @@ -37,6 +45,12 @@ export function defineLineShader(builder: ShaderBuilder, rounded = false) { builder.addVarying("highp float", "vLineCoord"); // max(1e-6, featherWidth) / (lineWidth + featherWidth) builder.addVarying("highp float", "vLineFeatherFraction"); + if (endpointClipping) { + builder.addVarying("highp float", "vLineOffsetX"); + builder.addVarying("highp float", "vLineLengthInPixels", "flat"); + builder.addVarying("highp float", "vLineHalfWidthInPixels", "flat"); + builder.addVarying("highp float", "vLineEndpointClipRadius", "flat"); + } if (rounded) { // Fraction of total line length used by each endpoint. builder.addVarying("highp float", "vEndpointFraction"); @@ -50,7 +64,8 @@ vec2 getLineOffset() { return getQuadVertexPosition(vec2(0.0, -1.0), vec2(1.0, 1 float getLineEndpointCoefficient() { return getLineOffset().x; } uint getLineEndpointIndex() { return uint(getLineEndpointCoefficient()); } void emitLine(vec4 vertexAClip, vec4 vertexBClip, float lineWidthInPixels - ${rounded ? ", float borderWidth" : ""}) { + ${rounded ? ", float borderWidth" : ""} + ${endpointClipping ? ", float endpointClipRadiusInPixels" : ""}) { if (!clipLineToDepthRange(vertexAClip, vertexBClip)) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; @@ -85,6 +100,14 @@ void emitLine(vec4 vertexAClip, vec4 vertexBClip, float lineWidthInPixels }) * totalLineWidth * uLineParams.xy; vLineCoord = lineOffset.y; + ${ + endpointClipping + ? `vLineOffsetX = lineOffset.x; + vLineLengthInPixels = linePixelLength; + vLineHalfWidthInPixels = totalLineWidth * 0.5; + vLineEndpointClipRadius = endpointClipRadiusInPixels;` + : "" + } ${ rounded ? "vEndpointFraction = totalLineWidth / (linePixelLength + totalLineWidth * 2.0);" @@ -97,10 +120,12 @@ void emitLine(vec4 vertexAClip, vec4 vertexBClip, float lineWidthInPixels } } void emitLine(mat4 projection, vec3 vertexA, vec3 vertexB, float lineWidthInPixels - ${rounded ? ", float borderWidth" : ""}) { + ${rounded ? ", float borderWidth" : ""} + ${endpointClipping ? ", float endpointClipRadiusInPixels" : ""}) { emitLine(projection * vec4(vertexA, 1.0), projection * vec4(vertexB, 1.0), lineWidthInPixels - ${rounded ? ", borderWidth" : ""}); + ${rounded ? ", borderWidth" : ""} + ${endpointClipping ? ", endpointClipRadiusInPixels" : ""}); } `); if (rounded) { @@ -126,6 +151,16 @@ vec4 getRoundedLineColor(vec4 interiorColor, vec4 borderColor) { builder.addFragmentCode(` float getLineAlpha() { + ${ + endpointClipping + ? `if (vLineEndpointClipRadius > 0.0) { + float offsetY = vLineCoord * vLineHalfWidthInPixels; + float distFromA = length(vec2(vLineOffsetX * vLineLengthInPixels, offsetY)); + float distFromB = length(vec2((1.0 - vLineOffsetX) * vLineLengthInPixels, offsetY)); + if (min(distFromA, distFromB) < vLineEndpointClipRadius) discard; + }` + : "" + } return clamp((1.0 - abs(vLineCoord)) / vLineFeatherFraction, 0.0, 1.0); } `); From c8447d3b5484516e8952aed4b05f2c02a865801e Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 20 Aug 2026 14:17:12 +0200 Subject: [PATCH 02/33] feat: allow to emit a custom depth Old calls just pass gl fragcoord.z as this depth --- src/perspective_view/panel.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/perspective_view/panel.ts b/src/perspective_view/panel.ts index 735bd26873..cf6480a02b 100644 --- a/src/perspective_view/panel.ts +++ b/src/perspective_view/panel.ts @@ -112,13 +112,16 @@ enum TransparentRenderingState { } export const glsl_perspectivePanelEmit = ` -void emit(vec4 color, highp uint pickId) { +void emit(vec4 color, highp float depth, highp uint pickId) { out_color = color; - float zValue = 1.0 - gl_FragCoord.z; + float zValue = 1.0 - depth; out_z = vec4(zValue, zValue, zValue, 1.0); float pickIdFloat = float(pickId); out_pickId = vec4(pickIdFloat, pickIdFloat, pickIdFloat, 1.0); } +void emit(vec4 color, highp uint pickId) { + emit(color, gl_FragCoord.z, pickId); +} `; /** @@ -142,11 +145,14 @@ void emitAccumAndRevealage(vec4 accum, float revealage, highp uint pickId) { v4f_fragData0 = vec4(accum.rgb, revealage); v4f_fragData1 = vec4(accum.a, 0.0, 0.0, 0.0); } -void emit(vec4 color, highp uint pickId) { - float weight = computeOITWeight(color.a, gl_FragCoord.z); +void emit(vec4 color, highp float depth, highp uint pickId) { + float weight = computeOITWeight(color.a, depth); vec4 accum = color * weight; emitAccumAndRevealage(accum, color.a, pickId); } +void emit(vec4 color, highp uint pickId) { + emit(color, gl_FragCoord.z, pickId); +} `, ]; From a86efb98ac632dbbeb114202ede52663ac050520 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 20 Aug 2026 15:17:23 +0200 Subject: [PATCH 03/33] feat: add raycast primitive generic contract and utils These are screen space camera facing quads where geometry hit/miss, depth, and lighting contribution are determined via raycast (and generally intended to be an analytic raycast for speed not a raymarch) --- src/webgl/raycast_primitive.ts | 177 +++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 src/webgl/raycast_primitive.ts diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts new file mode 100644 index 0000000000..9ff1b7a463 --- /dev/null +++ b/src/webgl/raycast_primitive.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file Shared GLSL for raycast primitives: a camera-facing quad whose fragment + * shader ray casts to find the 3D surface, writes `gl_FragDepth` and shades a + * normal. Intersection is in model space and must be transformed into + * display-space, similar to `src/annotation/ellipsoid.ts`. + * + * `emitRaycastBoundingQuad` is an AABB that any primitive can use. One whose shape + * admits a tighter bound can define its own instead, as `raycast_cylinder.ts` does. + */ + +import { mat4 } from "#src/util/geom.js"; +import { glsl_getQuadVertexPosition } from "#src/webgl/quad.js"; +import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; + +export const raycastProjectionUniform = (builder: ShaderBuilder) => { + builder.addUniform("highp mat4", "uProjection"); +}; + +const glsl_raycastPrimitiveFragmentUtil = ` +struct RaycastRay { + highp vec3 origin; + highp vec3 direction; +}; +struct RaycastHit { + bool hit; + highp float windowDepth; + highp float lightingFactor; +}; + +highp float raycastSurfaceDepth = 0.0; +highp float raycastLightingFactor = 1.0; + +RaycastRay getRaycastEyeRay() { + highp vec2 ndc = (gl_FragCoord.xy / uViewportSize) * 2.0 - 1.0; + highp vec4 nearClip = uInvProjection * vec4(ndc, -1.0, 1.0); + highp vec4 farClip = uInvProjection * vec4(ndc, 1.0, 1.0); + highp vec3 nearModel = nearClip.xyz / nearClip.w; + highp vec3 farModel = farClip.xyz / farClip.w; + RaycastRay ray; + ray.origin = nearModel; + ray.direction = normalize(farModel - nearModel); + return ray; +} +highp float getRaycastWindowDepth(highp vec3 modelPoint) { + // Assumes the default depth range [0, 1] and NDC z in [-1, 1]. + highp vec4 clip = uProjection * vec4(modelPoint, 1.0); + return 0.5 * (clip.z / clip.w) + 0.5; +} +// modelNormal can be non-normalized. +highp float getRaycastSurfaceLightingFactor(highp vec3 modelNormal) { + highp vec3 displayNormal = normalize(uNormalTransform * modelNormal); + return abs(dot(displayNormal, uLightDirection.xyz)) + uLightDirection.w; +} +RaycastHit raycastMiss() { + RaycastHit hit; + hit.hit = false; + return hit; +} +RaycastHit makeRaycastHit(highp vec3 surfacePoint, highp vec3 modelNormal) { + RaycastHit hit; + hit.hit = true; + hit.windowDepth = getRaycastWindowDepth(surfacePoint); + hit.lightingFactor = getRaycastSurfaceLightingFactor(modelNormal); + return hit; +} +`; + +// Emits the screen-axis-aligned quad covering the box +// `center +/- halfExtentU +/- halfExtentV +/- halfExtentW`. +// +// A corner on or behind the near plane must not be dropped -- that would +// under-cover a primitive straddling the near plane and leave it undrawn -- so its +// w is floored positive, which projects it far off-screen; NDC is clamped so that +// expansion stays finite. Once a corner is clamped the projected-corner hull no +// longer bounds the silhouette, which is what the relative margin covers. +const glsl_raycastPrimitiveVertexUtil = ` +const highp float RAYCAST_NDC_BOUND = 2.0; +void emitRaycastBoundingQuad(highp vec3 center, highp vec3 halfExtentU, + highp vec3 halfExtentV, highp vec3 halfExtentW) { + // Projection is linear before the divide, so the 8 corners come from 4 products. + highp vec4 clipCenter = uProjection * vec4(center, 1.0); + highp vec4 clipU = uProjection * vec4(halfExtentU, 0.0); + highp vec4 clipV = uProjection * vec4(halfExtentV, 0.0); + highp vec4 clipW = uProjection * vec4(halfExtentW, 0.0); + highp vec2 ndcMin = vec2(RAYCAST_NDC_BOUND); + highp vec2 ndcMax = vec2(-RAYCAST_NDC_BOUND); + highp float ndcNearZ = 1.0; + for (int corner = 0; corner < 8; ++corner) { + highp vec4 clip = clipCenter + + ((corner & 1) == 0 ? -clipU : clipU) + + ((corner & 2) == 0 ? -clipV : clipV) + + ((corner & 4) == 0 ? -clipW : clipW); + highp float clippedW = max(clip.w, 1e-4); + highp vec2 ndcXY = + clamp(clip.xy / clippedW, vec2(-RAYCAST_NDC_BOUND), vec2(RAYCAST_NDC_BOUND)); + ndcMin = min(ndcMin, ndcXY); + ndcMax = max(ndcMax, ndcXY); + ndcNearZ = min(ndcNearZ, clamp(clip.z / clippedW, -1.0, 1.0)); + } + highp vec2 margin = (ndcMax - ndcMin) * 0.02 + 2.0 / uViewportSize; + highp vec2 quadCorner = getQuadVertexPosition(ndcMin - margin, ndcMax + margin); + gl_Position = vec4(quadCorner, ndcNearZ, 1.0); +} +`; + +// Model-space radius projecting to `radiusInPixels` device px at `modelPoint`, +// measured on the vertical viewport extent, so raycasts hold a constant on-screen +// size like the billboards they replace. +const glsl_raycastPrimitivePixelRadius = ` +highp float getRaycastModelRadiusForPixels(highp vec3 modelPoint, highp float radiusInPixels) { + highp float clipW = max((uProjection * vec4(modelPoint, 1.0)).w, 1e-6); + // uInvProjection column 1 is one NDC unit of y in model space; the positive + // scalar factors straight out of the length. + return length(uInvProjection[1].xyz) * (2.0 / uViewportSize.y) * clipW * radiusInPixels; +} +`; + +export const glsl_raycastFragmentSetup = ` +RaycastHit raycastHit = intersectRaycastPrimitive(); +if (!raycastHit.hit) discard; +// Positive-form range test, so a NaN depth from a degenerate projection is +// rejected rather than poisoning the OIT weight. +if (!(raycastHit.windowDepth >= 0.0 && raycastHit.windowDepth <= 1.0)) discard; +gl_FragDepth = raycastHit.windowDepth; +raycastSurfaceDepth = raycastHit.windowDepth; +raycastLightingFactor = raycastHit.lightingFactor; +`; + +export function defineRaycastPrimitiveCommon(builder: ShaderBuilder) { + builder.require(raycastProjectionUniform); + builder.addUniform("highp mat4", "uInvProjection"); + builder.addUniform("highp mat3", "uNormalTransform"); + builder.addUniform("highp vec4", "uLightDirection"); + builder.addUniform("highp vec2", "uViewportSize"); + builder.addVertexCode(glsl_getQuadVertexPosition); + builder.addVertexCode(glsl_raycastPrimitiveVertexUtil); + builder.addVertexCode(glsl_raycastPrimitivePixelRadius); + builder.addFragmentCode(glsl_raycastPrimitiveFragmentUtil); +} + +const tempInvProjection = mat4.create(); + +export function initializeRaycastPrimitiveShader( + shader: ShaderProgram, + modelClip: mat4, + projectionParameters: { width: number; height: number }, +) { + const { gl } = shader; + gl.uniformMatrix4fv(shader.uniform("uProjection"), false, modelClip); + mat4.invert(tempInvProjection, modelClip); + gl.uniformMatrix4fv( + shader.uniform("uInvProjection"), + false, + tempInvProjection, + ); + gl.uniform2f( + shader.uniform("uViewportSize"), + projectionParameters.width, + projectionParameters.height, + ); +} From 64bb7590e95ec048dfdf52385355df881b74d624 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 20 Aug 2026 16:00:11 +0200 Subject: [PATCH 04/33] refactor: simplify OBB to AABB in raycast The old cylinder used an OBB but the new one doesn't and AABB is faster and simpler --- src/webgl/raycast_primitive.ts | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index 9ff1b7a463..0274019cf1 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -81,8 +81,8 @@ RaycastHit makeRaycastHit(highp vec3 surfacePoint, highp vec3 modelNormal) { } `; -// Emits the screen-axis-aligned quad covering the box -// `center +/- halfExtentU +/- halfExtentV +/- halfExtentW`. +// Emits the screen-axis-aligned quad covering the model-space box +// `center +/- halfExtent`. // // A corner on or behind the near plane must not be dropped -- that would // under-cover a primitive straddling the near plane and leave it undrawn -- so its @@ -91,27 +91,26 @@ RaycastHit makeRaycastHit(highp vec3 surfacePoint, highp vec3 modelNormal) { // longer bounds the silhouette, which is what the relative margin covers. const glsl_raycastPrimitiveVertexUtil = ` const highp float RAYCAST_NDC_BOUND = 2.0; -void emitRaycastBoundingQuad(highp vec3 center, highp vec3 halfExtentU, - highp vec3 halfExtentV, highp vec3 halfExtentW) { - // Projection is linear before the divide, so the 8 corners come from 4 products. +void emitRaycastBoundingQuad(highp vec3 center, highp vec3 halfExtent) { + // Projection is linear before the divide, so each axis is one scaled column. highp vec4 clipCenter = uProjection * vec4(center, 1.0); - highp vec4 clipU = uProjection * vec4(halfExtentU, 0.0); - highp vec4 clipV = uProjection * vec4(halfExtentV, 0.0); - highp vec4 clipW = uProjection * vec4(halfExtentW, 0.0); + highp vec4 clipX = uProjection[0] * halfExtent.x; + highp vec4 clipY = uProjection[1] * halfExtent.y; + highp vec4 clipZ = uProjection[2] * halfExtent.z; highp vec2 ndcMin = vec2(RAYCAST_NDC_BOUND); highp vec2 ndcMax = vec2(-RAYCAST_NDC_BOUND); highp float ndcNearZ = 1.0; for (int corner = 0; corner < 8; ++corner) { highp vec4 clip = clipCenter - + ((corner & 1) == 0 ? -clipU : clipU) - + ((corner & 2) == 0 ? -clipV : clipV) - + ((corner & 4) == 0 ? -clipW : clipW); - highp float clippedW = max(clip.w, 1e-4); + + ((corner & 1) == 0 ? -clipX : clipX) + + ((corner & 2) == 0 ? -clipY : clipY) + + ((corner & 4) == 0 ? -clipZ : clipZ); + highp float clipW = max(clip.w, 1e-4); highp vec2 ndcXY = - clamp(clip.xy / clippedW, vec2(-RAYCAST_NDC_BOUND), vec2(RAYCAST_NDC_BOUND)); + clamp(clip.xy / clipW, vec2(-RAYCAST_NDC_BOUND), vec2(RAYCAST_NDC_BOUND)); ndcMin = min(ndcMin, ndcXY); ndcMax = max(ndcMax, ndcXY); - ndcNearZ = min(ndcNearZ, clamp(clip.z / clippedW, -1.0, 1.0)); + ndcNearZ = min(ndcNearZ, clamp(clip.z / clipW, -1.0, 1.0)); } highp vec2 margin = (ndcMax - ndcMin) * 0.02 + 2.0 / uViewportSize; highp vec2 quadCorner = getQuadVertexPosition(ndcMin - margin, ndcMax + margin); From 4936ba0aced44d750903dd3e91c05e9b66f928e6 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 20 Aug 2026 17:12:02 +0200 Subject: [PATCH 05/33] feat: add sphere raycast primitive This is formed by: 1. Find AABB of the sphere. Project the 8 corners of the AABB into screen space and then draw 6 verts (two tris) to represent the quad in screen space which covers the AABB as the vertex shader. For now each invocation redoes the projection, which could possibly be avoided but seems a small win. Rest happens in frag shader. 2. Find the ray from the eye (camera) to the fragment, in model space. 3. With this ray, see if a part of the ray forms a chord through the sphere. To do this, get the shortest distance from the ray to the sphere center (which is the perpendicular) and see if the right angled triangle that would be formed between a radial line from the sphere center and the perpendicular could exist. 4. If the above holds, so there is a chord, ensure that the ray doesn't need to travel backwards behind the near plane to form the chord (which would be negative hit distance) 5. If no hit, discard. If hit, continue with regular depth and lighting calculation based on the hit distance. --- src/webgl/raycast_sphere.ts | 88 +++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/webgl/raycast_sphere.ts diff --git a/src/webgl/raycast_sphere.ts b/src/webgl/raycast_sphere.ts new file mode 100644 index 0000000000..e04317ab76 --- /dev/null +++ b/src/webgl/raycast_sphere.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file Raycast sphere drawn on a camera-facing quad; see `raycast_primitive.ts` + * for the shared conventions. + * + * Adapted from Inigo Quilez's sphere intersector + * (https://iquilezles.org/articles/intersectors/) and related shadertoy code. + * + * The MIT License. Copyright (c) 2016 Inigo Quilez. + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: the above copyright + * notice and this permission notice shall be included in all copies or + * substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS". + * + * Modifications: + * - The bounding-quad vertex stage has no counterpart in the original, which + * ray-marches a full-screen quad. + * - The discriminant uses the perpendicular-distance rearrangement instead of + * `c = dot(oc, oc) - r * r`; see `intersectRaycastPrimitive` below. + * This is for better scaling as neuroglancer can have large depth range. + * - Returns depth and a lighting factor rather than a ray distance. + */ + +import { defineRaycastPrimitiveCommon } from "#src/webgl/raycast_primitive.js"; +import type { ShaderBuilder } from "#src/webgl/shader.js"; + +/** + * Adds `emitRaycastSphere(center, radius)` (vertex) and + * `intersectRaycastPrimitive()` (fragment); `center`/`radius` are in model space. + */ +export function defineRaycastSphereShader(builder: ShaderBuilder) { + defineRaycastPrimitiveCommon(builder); + builder.addVarying("highp vec3", "vSphereCenter", "flat"); + builder.addVarying("highp float", "vSphereRadius", "flat"); + builder.addVertexCode(` +void emitRaycastSphere(highp vec3 center, highp float radius) { + vSphereCenter = center; + vSphereRadius = radius; + emitRaycastBoundingQuad(center, vec3(radius)); +} +`); + builder.addFragmentCode(` +RaycastHit intersectRaycastPrimitive() { + RaycastRay ray = getRaycastEyeRay(); + + // ray.direction is a unit vector, so this projection locates where the ray passes + // closest to the centre: at t = -projectedDistance, offset by perpendicular. + // The half-chord is the third side of a right triangle with hypotenuse radius and + // leg perpendicular, so it exists only while halfChordSquared is non-negative; + // the two surface crossings are then at -projectedDistance -/+ halfChord. + highp vec3 centerToOrigin = ray.origin - vSphereCenter; + highp float projectedDistance = dot(centerToOrigin, ray.direction); + highp vec3 perpendicular = centerToOrigin - projectedDistance * ray.direction; + highp float halfChordSquared = + vSphereRadius * vSphereRadius - dot(perpendicular, perpendicular); + // Positive-form guards, so a NaN ray from a degenerate projection misses + // rather than slipping through (NaN < 0.0 is false). + if (!(halfChordSquared >= 0.0)) return raycastMiss(); // triangle cannot close + highp float halfChord = sqrt(halfChordSquared); + highp float hitDistance = -projectedDistance - halfChord; + // Near crossing behind the origin means the origin is inside the sphere, so use + // the far one; if that is behind too the whole sphere is. + if (hitDistance < 0.0) hitDistance = -projectedDistance + halfChord; + if (!(hitDistance >= 0.0)) return raycastMiss(); + highp vec3 offsetFromCenter = centerToOrigin + hitDistance * ray.direction; + return makeRaycastHit(ray.origin + hitDistance * ray.direction, offsetFromCenter); +} +`); +} From 1e300cf42ba1a0465fc991d255954a85e71ba74b Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 20 Aug 2026 18:17:44 +0200 Subject: [PATCH 06/33] feat: change inside sphere to a miss the analogy here is similar to when a camera in a game goes behind a wall, often you then don't render the wall because you're inside the wall. We'd have the same issue of the camera getting trapped inside a sphere if the sphere was big enough. --- src/webgl/raycast_sphere.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/webgl/raycast_sphere.ts b/src/webgl/raycast_sphere.ts index e04317ab76..6f008f69ff 100644 --- a/src/webgl/raycast_sphere.ts +++ b/src/webgl/raycast_sphere.ts @@ -76,10 +76,10 @@ RaycastHit intersectRaycastPrimitive() { // rather than slipping through (NaN < 0.0 is false). if (!(halfChordSquared >= 0.0)) return raycastMiss(); // triangle cannot close highp float halfChord = sqrt(halfChordSquared); + // Only the near crossing is drawn. A negative one means the sphere is behind us or + // the origin is inside it, and drawing the far surface then fills the view when the + // camera clips inside the geometry. highp float hitDistance = -projectedDistance - halfChord; - // Near crossing behind the origin means the origin is inside the sphere, so use - // the far one; if that is behind too the whole sphere is. - if (hitDistance < 0.0) hitDistance = -projectedDistance + halfChord; if (!(hitDistance >= 0.0)) return raycastMiss(); highp vec3 offsetFromCenter = centerToOrigin + hitDistance * ray.direction; return makeRaycastHit(ray.origin + hitDistance * ray.direction, offsetFromCenter); From 7726525d1de506443bdc8ce724d5aacc549841a7 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 21 Aug 2026 18:28:58 +0200 Subject: [PATCH 07/33] feat: add OBB for objects with a long axis Follows a very similar process to the AABB, where we provide the parameterization of the OBB to the vertex shader, and construct the projection to clip space of the model space OBB to find a screen space quad which covers the screen space (depth clipped) OBB. However the calculations are a bit more complex since we have a OBB instead of an AABB. --- src/webgl/raycast_primitive.ts | 92 ++++++++++++++++++++++++++++++---- src/webgl/raycast_sphere.ts | 2 +- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index 0274019cf1..662c7a2981 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -15,18 +15,26 @@ */ /** - * @file Shared GLSL for raycast primitives: a camera-facing quad whose fragment - * shader ray casts to find the 3D surface, writes `gl_FragDepth` and shades a - * normal. Intersection is in model space and must be transformed into - * display-space, similar to `src/annotation/ellipsoid.ts`. + * @file Shared GLSL for raycast primitives: a camera facing screen space quad + * whose fragment shader ray casts to find the 3D surface. + * Emits the depth, normal, and lighting factor. + * Intersection is in model space and must be transformed after finding the hit + * similar to `src/annotation/ellipsoid.ts`. * - * `emitRaycastBoundingQuad` is an AABB that any primitive can use. One whose shape - * admits a tighter bound can define its own instead, as `raycast_cylinder.ts` does. + * `emitRaycastAabbQuad` and `emitRaycastAxialObbQuad` bound a primitive for + * rasterisation by bounding the object in model space - then projecting to + * screen space and emit the screen space quad which covers the + * projected bounding box. + * Use the AABB (axis aligned bounding box) for objects like spheres, cubes + * and other fairly uniform geometries. + * Use the axial OBB (oriented bounding box) for objects with one defined long + * axis, like cylinders, capsules, cones, etc. */ import { mat4 } from "#src/util/geom.js"; import { glsl_getQuadVertexPosition } from "#src/webgl/quad.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; +import { glsl_clipLineToDepthRange } from "#src/webgl/shader_lib.js"; export const raycastProjectionUniform = (builder: ShaderBuilder) => { builder.addUniform("highp mat4", "uProjection"); @@ -89,10 +97,9 @@ RaycastHit makeRaycastHit(highp vec3 surfacePoint, highp vec3 modelNormal) { // w is floored positive, which projects it far off-screen; NDC is clamped so that // expansion stays finite. Once a corner is clamped the projected-corner hull no // longer bounds the silhouette, which is what the relative margin covers. -const glsl_raycastPrimitiveVertexUtil = ` +const glsl_raycastAabbQuad = ` const highp float RAYCAST_NDC_BOUND = 2.0; -void emitRaycastBoundingQuad(highp vec3 center, highp vec3 halfExtent) { - // Projection is linear before the divide, so each axis is one scaled column. +void emitRaycastAabbQuad(highp vec3 center, highp vec3 halfExtent) { highp vec4 clipCenter = uProjection * vec4(center, 1.0); highp vec4 clipX = uProjection[0] * halfExtent.x; highp vec4 clipY = uProjection[1] * halfExtent.y; @@ -100,6 +107,7 @@ void emitRaycastBoundingQuad(highp vec3 center, highp vec3 halfExtent) { highp vec2 ndcMin = vec2(RAYCAST_NDC_BOUND); highp vec2 ndcMax = vec2(-RAYCAST_NDC_BOUND); highp float ndcNearZ = 1.0; + for (int corner = 0; corner < 8; ++corner) { highp vec4 clip = clipCenter + ((corner & 1) == 0 ? -clipX : clipX) @@ -112,12 +120,74 @@ void emitRaycastBoundingQuad(highp vec3 center, highp vec3 halfExtent) { ndcMax = max(ndcMax, ndcXY); ndcNearZ = min(ndcNearZ, clamp(clip.z / clipW, -1.0, 1.0)); } + highp vec2 margin = (ndcMax - ndcMin) * 0.02 + 2.0 / uViewportSize; highp vec2 quadCorner = getQuadVertexPosition(ndcMin - margin, ndcMax + margin); gl_Position = vec4(quadCorner, ndcNearZ, 1.0); } `; +// An OBB about the segment endpointA..endpointB with radial half-extents +// radiusVectorA/B, emitted as a quad oriented along the projected axis. +// +// Depth-clipping the segment first is what makes an oriented quad possible at all. +// It bounds a primitive crossing the eye plane, whose footprint is otherwise +// unbounded, and it leaves every corner in front of the eye, where the +// projected-corner hull is a valid bound and the screen basis below is real. If a +// corner still grazes the eye plane there is no valid basis, so cover the screen. +const glsl_raycastAxialObbQuad = ` +highp vec2 raycastClipToPixels(highp vec4 clip) { + return clip.xy / clip.w * uViewportSize * 0.5; +} +void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, + highp vec3 radiusVectorA, highp vec3 radiusVectorB) { + highp vec4 clipA = uProjection * vec4(endpointA, 1.0); + highp vec4 clipB = uProjection * vec4(endpointB, 1.0); + highp vec4 clipVectorA = uProjection * vec4(radiusVectorA, 0.0); + highp vec4 clipVectorB = uProjection * vec4(radiusVectorB, 0.0); + + // Clips clipA and clipB in place, so everything below uses the clipped segment. + bool clipped = clipLineToDepthRange(clipA, clipB); + highp float minW = + min(clipA.w, clipB.w) - abs(clipVectorA.w) - abs(clipVectorB.w); + highp vec2 quadCoefficient = getQuadVertexPosition(vec2(-1.0), vec2(1.0)); + + // Positive-form test, so a NaN from a degenerate clip also covers the screen. + if (!(clipped && minW > 1e-4 * max(clipA.w, clipB.w))) { + gl_Position = vec4(quadCoefficient, 0.0, 1.0); + return; + } + + highp vec2 pixelsA = raycastClipToPixels(clipA); + highp vec2 pixelsB = raycastClipToPixels(clipB); + highp vec2 axisPixels = pixelsB - pixelsA; + highp float axisLengthPixels = length(axisPixels); + highp vec2 alongDirection = + axisLengthPixels > 1e-3 ? axisPixels / axisLengthPixels : vec2(1.0, 0.0); + highp vec2 perpDirection = vec2(-alongDirection.y, alongDirection.x); + highp vec2 pixelCenter = (pixelsA + pixelsB) * 0.5; + highp float halfAlongPixels = 0.0; + highp float halfPerpPixels = 0.0; + highp float ndcNearZ = 1.0; + + for (int corner = 0; corner < 8; ++corner) { + highp vec4 clip = ((corner & 1) == 0 ? clipA : clipB) + + ((corner & 2) == 0 ? -clipVectorA : clipVectorA) + + ((corner & 4) == 0 ? -clipVectorB : clipVectorB); + highp vec2 offset = raycastClipToPixels(clip) - pixelCenter; + halfAlongPixels = max(halfAlongPixels, abs(dot(offset, alongDirection))); + halfPerpPixels = max(halfPerpPixels, abs(dot(offset, perpDirection))); + ndcNearZ = min(ndcNearZ, clamp(clip.z / clip.w, -1.0, 1.0)); + } + + // One pixel for numerical error; the corner bound is otherwise exact. + highp vec2 pixels = pixelCenter + + alongDirection * (quadCoefficient.x * (halfAlongPixels + 1.0)) + + perpDirection * (quadCoefficient.y * (halfPerpPixels + 1.0)); + gl_Position = vec4(pixels * 2.0 / uViewportSize, ndcNearZ, 1.0); +} +`; + // Model-space radius projecting to `radiusInPixels` device px at `modelPoint`, // measured on the vertical viewport extent, so raycasts hold a constant on-screen // size like the billboards they replace. @@ -148,7 +218,9 @@ export function defineRaycastPrimitiveCommon(builder: ShaderBuilder) { builder.addUniform("highp vec4", "uLightDirection"); builder.addUniform("highp vec2", "uViewportSize"); builder.addVertexCode(glsl_getQuadVertexPosition); - builder.addVertexCode(glsl_raycastPrimitiveVertexUtil); + builder.addVertexCode(glsl_clipLineToDepthRange); + builder.addVertexCode(glsl_raycastAabbQuad); + builder.addVertexCode(glsl_raycastAxialObbQuad); builder.addVertexCode(glsl_raycastPrimitivePixelRadius); builder.addFragmentCode(glsl_raycastPrimitiveFragmentUtil); } diff --git a/src/webgl/raycast_sphere.ts b/src/webgl/raycast_sphere.ts index 6f008f69ff..6f2f2286b8 100644 --- a/src/webgl/raycast_sphere.ts +++ b/src/webgl/raycast_sphere.ts @@ -55,7 +55,7 @@ export function defineRaycastSphereShader(builder: ShaderBuilder) { void emitRaycastSphere(highp vec3 center, highp float radius) { vSphereCenter = center; vSphereRadius = radius; - emitRaycastBoundingQuad(center, vec3(radius)); + emitRaycastAabbQuad(center, vec3(radius)); } `); builder.addFragmentCode(` From 07501c5e9959c05808f2b635bd784279cb4f378a Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 21 Aug 2026 18:39:18 +0200 Subject: [PATCH 08/33] refactor: clarify reason for NDC bound > 1.0 --- src/webgl/raycast_primitive.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index 662c7a2981..5d98f3aa8d 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -94,18 +94,20 @@ RaycastHit makeRaycastHit(highp vec3 surfacePoint, highp vec3 modelNormal) { // // A corner on or behind the near plane must not be dropped -- that would // under-cover a primitive straddling the near plane and leave it undrawn -- so its -// w is floored positive, which projects it far off-screen; NDC is clamped so that -// expansion stays finite. Once a corner is clamped the projected-corner hull no -// longer bounds the silhouette, which is what the relative margin covers. +// w is floored positive, which projects it far off-screen, and its NDC is then +// clamped to keep the box finite. Once a corner is clamped the projected-corner +// hull no longer bounds the silhouette, which is what the relative margin covers. const glsl_raycastAabbQuad = ` -const highp float RAYCAST_NDC_BOUND = 2.0; +// Must exceed 1.0 as pinned exactly at the viewport edge, the margin added +// later would drag a fully off-screen primitive back on screen as a sliver. +const highp float RAYCAST_OFFSCREEN_NDC = 2.0; void emitRaycastAabbQuad(highp vec3 center, highp vec3 halfExtent) { highp vec4 clipCenter = uProjection * vec4(center, 1.0); highp vec4 clipX = uProjection[0] * halfExtent.x; highp vec4 clipY = uProjection[1] * halfExtent.y; highp vec4 clipZ = uProjection[2] * halfExtent.z; - highp vec2 ndcMin = vec2(RAYCAST_NDC_BOUND); - highp vec2 ndcMax = vec2(-RAYCAST_NDC_BOUND); + highp vec2 ndcMin = vec2(RAYCAST_OFFSCREEN_NDC); + highp vec2 ndcMax = vec2(-RAYCAST_OFFSCREEN_NDC); highp float ndcNearZ = 1.0; for (int corner = 0; corner < 8; ++corner) { @@ -115,7 +117,7 @@ void emitRaycastAabbQuad(highp vec3 center, highp vec3 halfExtent) { + ((corner & 4) == 0 ? -clipZ : clipZ); highp float clipW = max(clip.w, 1e-4); highp vec2 ndcXY = - clamp(clip.xy / clipW, vec2(-RAYCAST_NDC_BOUND), vec2(RAYCAST_NDC_BOUND)); + clamp(clip.xy / clipW, vec2(-RAYCAST_OFFSCREEN_NDC), vec2(RAYCAST_OFFSCREEN_NDC)); ndcMin = min(ndcMin, ndcXY); ndcMax = max(ndcMax, ndcXY); ndcNearZ = min(ndcNearZ, clamp(clip.z / clipW, -1.0, 1.0)); From fef2d9865be81a922cc3c74964a42d36a7b69362 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 21 Aug 2026 18:58:36 +0200 Subject: [PATCH 09/33] feat: add raycast cylinder Adds a raycast cylinder building on the OBB form in the generic file. The vertex shader setup is quite small with the generic helper. For the fragment shader we split the problem into two parts. We start by considering the line that represents the primary axis going through the center of the cylinder, which we have from its two endpoints. Then for this axis line, we find the plane perpendicular to the axis line. We check in that plane in a very similar manner to whether in our sphere code the radial line and the perpendicular could form a right angled triangle or not to see if we have a hit with the cylinder in the plane. If we do, then we just need to check if the hit lies outside the ends of the cylinder when viewed along the main axis running through the center of the cylinder. If it is within a sufficient distance, we can consider the ray to be a hit. To split into the axial component and the planar component we use the dot product. The hit point is fairly simple from there in the plane, and for the normal, since we constructed the plane to be perpendicular to the axis line, we can use the information about the orientation of the plane to determine the normal as the normal will be in the same direction as the in-plane radial line starting from the cylinder central axis line. --- src/webgl/raycast_cylinder.ts | 132 ++++++++++++++++++++ src/webgl/raycast_primitive.browser_test.ts | 60 +++++++++ src/webgl/raycast_primitive.ts | 2 +- 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 src/webgl/raycast_cylinder.ts create mode 100644 src/webgl/raycast_primitive.browser_test.ts diff --git a/src/webgl/raycast_cylinder.ts b/src/webgl/raycast_cylinder.ts new file mode 100644 index 0000000000..f5452838be --- /dev/null +++ b/src/webgl/raycast_cylinder.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file Raycast open-ended cylinder drawn on a camera-facing quad; see + * `raycast_primitive.ts` for the shared conventions. + * + * Adapted from Inigo Quilez's cylinder intersector + * (https://iquilezles.org/articles/intersectors/, + * https://www.shadertoy.com/view/4lcSRn), MIT licensed: + * + * The MIT License. Copyright (c) 2016 Inigo Quilez. + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: the above copyright + * notice and this permission notice shall be included in all copies or + * substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS". + * + * Modifications: + * - The vertex stage has no counterpart in the original, which ray-marches a + * full-screen quad. It bounds the cylinder with + * `emitRaycastAxialObbQuad` and hands the axis frame to the fragment stage in + * `vCylinderAxis`. + * - The quadratic becomes a ray/circle test in the plane perpendicular to a unit + * axis, in the same perpendicular-distance form as `raycast_sphere.ts`; see + * `intersectRaycastPrimitive` below. + * - The end caps are dropped (skeleton joints are covered by spheres) and + * endpoint clipping is added. + * - Returns depth and a lighting factor rather than a ray distance and normal. + */ + +import { defineRaycastPrimitiveCommon } from "#src/webgl/raycast_primitive.js"; +import type { ShaderBuilder } from "#src/webgl/shader.js"; + +export function defineRaycastCylinderShader(builder: ShaderBuilder) { + defineRaycastPrimitiveCommon(builder); + builder.addVarying("highp vec3", "vCylinderEndpointA", "flat"); + builder.addVarying("highp vec3", "vCylinderEndpointB", "flat"); + // xyz: unit axis direction, w: axis length. + builder.addVarying("highp vec4", "vCylinderAxis", "flat"); + builder.addVarying("highp float", "vCylinderRadius", "flat"); + builder.addVarying("highp float", "vCylinderClipRadiusA", "flat"); + builder.addVarying("highp float", "vCylinderClipRadiusB", "flat"); + builder.addVertexCode(` +void emitRaycastCylinder(highp vec3 endpointA, highp vec3 endpointB, + highp float radius, + highp float clipRadiusA, highp float clipRadiusB) { + vCylinderEndpointA = endpointA; + vCylinderEndpointB = endpointB; + vCylinderRadius = radius; + vCylinderClipRadiusA = clipRadiusA; + vCylinderClipRadiusB = clipRadiusB; + highp vec3 axisVector = endpointB - endpointA; + highp float axisLength = length(axisVector); + highp vec3 axisDirection = axisLength > 1e-6 ? axisVector / axisLength : vec3(0.0, 1.0, 0.0); + vCylinderAxis = vec4(axisDirection, axisLength); + + // Find two perpendicular radius vectors spanning the circular cross-section. + highp vec3 offAxisVector = + abs(axisDirection.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0); + highp vec3 radiusVectorA = normalize(cross(offAxisVector, axisDirection)) * radius; + highp vec3 radiusVectorB = normalize(cross(axisDirection, radiusVectorA)) * radius; + emitRaycastAxialObbQuad(endpointA, endpointB, radiusVectorA, radiusVectorB); +} +`); + builder.addFragmentCode(` +bool cylinderPointClipped(highp vec3 surfacePoint) { + highp vec3 offsetA = surfacePoint - vCylinderEndpointA; + highp vec3 offsetB = surfacePoint - vCylinderEndpointB; + return dot(offsetA, offsetA) < vCylinderClipRadiusA * vCylinderClipRadiusA || + dot(offsetB, offsetB) < vCylinderClipRadiusB * vCylinderClipRadiusB; +} + +RaycastHit intersectRaycastPrimitive() { + RaycastRay ray = getRaycastEyeRay(); + highp vec3 axisDirection = vCylinderAxis.xyz; + highp float axisLength = vCylinderAxis.w; + + // Split the ray about the unit axis. In the plane perpendicular to the axis the + // cylinder is only a circle of vCylinderRadius centred on the axis, so the same + // right-triangle test as raycast_sphere.ts finds the crossing; the axial parts + // then say whether that crossing lies between the two endpoints. + highp vec3 baseToOrigin = ray.origin - vCylinderEndpointA; + highp float directionAlongAxis = dot(axisDirection, ray.direction); + highp float originAlongAxis = dot(axisDirection, baseToOrigin); + highp vec3 directionInPlane = ray.direction - directionAlongAxis * axisDirection; + highp vec3 originInPlane = baseToOrigin - originAlongAxis * axisDirection; + + // ray.direction is a unit vector, so this length is the sine of the angle between + // the ray and the axis. It scales ray distance into in-plane distance, and is zero + // exactly when the ray runs parallel to the axis. + highp float sinAngleToAxis = length(directionInPlane); + highp vec3 inPlaneDirection = directionInPlane / sinAngleToAxis; + highp float projectedDistance = dot(originInPlane, inPlaneDirection); + highp vec3 perpendicular = originInPlane - projectedDistance * inPlaneDirection; + highp float halfChordSquared = + vCylinderRadius * vCylinderRadius - dot(perpendicular, perpendicular); + + // Positive-form guards throughout, so a ray parallel to the axis (a zero + // sinAngleToAxis) and any NaN it produces miss rather than slipping through. + if (!(halfChordSquared >= 0.0)) return raycastMiss(); + highp float halfChord = sqrt(halfChordSquared); + // Only the near crossing is drawn, as in raycast_sphere.ts: a negative one means + // the cylinder is behind us or we are inside it, and both are a miss. + highp float hitDistance = (-projectedDistance - halfChord) / sinAngleToAxis; + if (!(hitDistance >= 0.0)) return raycastMiss(); + + highp float axialDistance = originAlongAxis + hitDistance * directionAlongAxis; + if (!(axialDistance >= 0.0 && axialDistance <= axisLength)) return raycastMiss(); + highp vec3 surfacePoint = ray.origin + hitDistance * ray.direction; + if (cylinderPointClipped(surfacePoint)) return raycastMiss(); + + return makeRaycastHit(surfacePoint, originInPlane + hitDistance * directionInPlane); +} +`); +} diff --git a/src/webgl/raycast_primitive.browser_test.ts b/src/webgl/raycast_primitive.browser_test.ts new file mode 100644 index 0000000000..9ffaca578b --- /dev/null +++ b/src/webgl/raycast_primitive.browser_test.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it } from "vitest"; +import { defineRaycastCylinderShader } from "#src/webgl/raycast_cylinder.js"; +import { glsl_raycastFragmentSetup } from "#src/webgl/raycast_primitive.js"; +import { defineRaycastSphereShader } from "#src/webgl/raycast_sphere.js"; +import { ShaderBuilder } from "#src/webgl/shader.js"; +import { webglTest } from "#src/webgl/testing.js"; + +function buildShader( + definePrimitive: (builder: ShaderBuilder) => void, + emitPrimitive: string, +) { + webglTest((gl) => { + const builder = new ShaderBuilder(gl); + builder.addOutputBuffer("vec4", "out_color", 0); + definePrimitive(builder); + builder.setVertexMain(emitPrimitive); + // Mirrors how a consumer emits: from a helper function, which can only see + // the published globals and not main's locals. + builder.addFragmentCode(` +void emitShaded() { + out_color = vec4(vec3(raycastLightingFactor), raycastSurfaceDepth); +} +`); + builder.setFragmentMain(glsl_raycastFragmentSetup + "emitShaded();\n"); + builder.build().dispose(); + }); +} + +describe("raycast primitives", () => { + it("compiles the sphere shader", () => { + buildShader( + defineRaycastSphereShader, + `emitRaycastSphere(vec3(0.0), getRaycastModelRadiusForPixels(vec3(0.0), 5.0));`, + ); + }); + + it("compiles the cylinder shader", () => { + buildShader( + defineRaycastCylinderShader, + `emitRaycastCylinder(vec3(0.0), vec3(0.0, 1.0, 0.0), + getRaycastModelRadiusForPixels(vec3(0.0), 2.0), 1.0, 1.0);`, + ); + }); +}); diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index 5d98f3aa8d..f9d4dde724 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -17,7 +17,7 @@ /** * @file Shared GLSL for raycast primitives: a camera facing screen space quad * whose fragment shader ray casts to find the 3D surface. - * Emits the depth, normal, and lighting factor. + * Emits the depth and a lighting factor. * Intersection is in model space and must be transformed after finding the hit * similar to `src/annotation/ellipsoid.ts`. * From c106e0a628dc1c9496db2ac7d042d8772b11ea37 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 21 Aug 2026 21:39:12 +0200 Subject: [PATCH 10/33] fix: GLSL nan is not always like IEEE 754 floating pt definition nan --- src/webgl/raycast_cylinder.ts | 9 ++++++--- src/webgl/raycast_primitive.ts | 6 +++--- src/webgl/raycast_sphere.ts | 4 ++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/webgl/raycast_cylinder.ts b/src/webgl/raycast_cylinder.ts index f5452838be..ada3b47774 100644 --- a/src/webgl/raycast_cylinder.ts +++ b/src/webgl/raycast_cylinder.ts @@ -104,16 +104,19 @@ RaycastHit intersectRaycastPrimitive() { // ray.direction is a unit vector, so this length is the sine of the angle between // the ray and the axis. It scales ray distance into in-plane distance, and is zero - // exactly when the ray runs parallel to the axis. + // exactly when the ray runs parallel to the axis, which never crosses the lateral + // surface. Guard that explicitly: directionInPlane is then the zero vector, and + // GLSL ES leaves 0.0 / 0.0 undefined rather than promising a NaN we could catch. highp float sinAngleToAxis = length(directionInPlane); + if (!(sinAngleToAxis > 0.0)) return raycastMiss(); highp vec3 inPlaneDirection = directionInPlane / sinAngleToAxis; highp float projectedDistance = dot(originInPlane, inPlaneDirection); highp vec3 perpendicular = originInPlane - projectedDistance * inPlaneDirection; highp float halfChordSquared = vCylinderRadius * vCylinderRadius - dot(perpendicular, perpendicular); - // Positive-form guards throughout, so a ray parallel to the axis (a zero - // sinAngleToAxis) and any NaN it produces miss rather than slipping through. + // Comparisons are in positive form so that a non-finite value misses rather than + // slipping through. Defence only: GLSL ES guarantees nothing about NaN. if (!(halfChordSquared >= 0.0)) return raycastMiss(); highp float halfChord = sqrt(halfChordSquared); // Only the near crossing is drawn, as in raycast_sphere.ts: a negative one means diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index f9d4dde724..d8125c0145 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -154,7 +154,7 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, min(clipA.w, clipB.w) - abs(clipVectorA.w) - abs(clipVectorB.w); highp vec2 quadCoefficient = getQuadVertexPosition(vec2(-1.0), vec2(1.0)); - // Positive-form test, so a NaN from a degenerate clip also covers the screen. + // Positive form, so a non-finite result falls back rather than proceeding. if (!(clipped && minW > 1e-4 * max(clipA.w, clipB.w))) { gl_Position = vec4(quadCoefficient, 0.0, 1.0); return; @@ -205,8 +205,8 @@ highp float getRaycastModelRadiusForPixels(highp vec3 modelPoint, highp float ra export const glsl_raycastFragmentSetup = ` RaycastHit raycastHit = intersectRaycastPrimitive(); if (!raycastHit.hit) discard; -// Positive-form range test, so a NaN depth from a degenerate projection is -// rejected rather than poisoning the OIT weight. +// Positive-form range test, so a non-finite depth is rejected rather than poisoning +// the OIT weight. if (!(raycastHit.windowDepth >= 0.0 && raycastHit.windowDepth <= 1.0)) discard; gl_FragDepth = raycastHit.windowDepth; raycastSurfaceDepth = raycastHit.windowDepth; diff --git a/src/webgl/raycast_sphere.ts b/src/webgl/raycast_sphere.ts index 6f2f2286b8..835e5a9193 100644 --- a/src/webgl/raycast_sphere.ts +++ b/src/webgl/raycast_sphere.ts @@ -72,8 +72,8 @@ RaycastHit intersectRaycastPrimitive() { highp vec3 perpendicular = centerToOrigin - projectedDistance * ray.direction; highp float halfChordSquared = vSphereRadius * vSphereRadius - dot(perpendicular, perpendicular); - // Positive-form guards, so a NaN ray from a degenerate projection misses - // rather than slipping through (NaN < 0.0 is false). + // Comparisons are in positive form so that a non-finite value misses rather than + // slipping through. Defence only: GLSL ES guarantees nothing about NaN. if (!(halfChordSquared >= 0.0)) return raycastMiss(); // triangle cannot close highp float halfChord = sqrt(halfChordSquared); // Only the near crossing is drawn. A negative one means the sphere is behind us or From 244de0eb3aeab47259018dca7233fcf828fc3eaf Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 24 Aug 2026 08:38:36 +0200 Subject: [PATCH 11/33] feat: add frontend control over new cylinder modes --- src/layer/segmentation/layer_controls.ts | 71 ++-- src/skeleton/frontend.ts | 493 +++++++++++++++-------- src/webgl/raycast_primitive.ts | 6 +- 3 files changed, 376 insertions(+), 194 deletions(-) diff --git a/src/layer/segmentation/layer_controls.ts b/src/layer/segmentation/layer_controls.ts index 8bcc268fa2..e2f3a3242f 100644 --- a/src/layer/segmentation/layer_controls.ts +++ b/src/layer/segmentation/layer_controls.ts @@ -1,5 +1,7 @@ import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import * as json_keys from "#src/layer/segmentation/json_keys.js"; +import type { SkeletonRenderMode } from "#src/skeleton/frontend.js"; +import type { TrackableEnum } from "#src/util/trackable_enum.js"; import type { LayerControlDefinition } from "#src/widget/layer_control.js"; import { registerLayerControl } from "#src/widget/layer_control.js"; import { checkboxLayerControl } from "#src/widget/layer_control_checkbox.js"; @@ -111,42 +113,49 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ title: "Highlight the segment under the mouse pointer", ...checkboxLayerControl((layer) => layer.displayState.hoverHighlight), }, - ...getViewSpecificSkeletonRenderingControl("2d"), - ...getViewSpecificSkeletonRenderingControl("3d"), + getSkeletonModeControl( + "2d", + (layer) => layer.displayState.skeletonRenderingOptions.params2d.mode, + ), + getSkeletonLineWidthControl("2d"), + getSkeletonModeControl( + "3d", + (layer) => layer.displayState.skeletonRenderingOptions.params3d.mode, + ), + getSkeletonLineWidthControl("3d"), ]; const maxSilhouettePower = 10; -function getViewSpecificSkeletonRenderingControl( +function getSkeletonModeControl( viewName: "2d" | "3d", -): LayerControlDefinition[] { - return [ - { - label: `Skeleton mode (${viewName})`, - toolJson: `${json_keys.SKELETON_RENDERING_JSON_KEY}.mode${viewName}`, - isValid: (layer) => layer.hasSkeletonsLayer, - ...enumLayerControl( - (layer) => - layer.displayState.skeletonRenderingOptions[ - `params${viewName}` as const - ].mode, - ), - }, - { - label: `Line width (${viewName})`, - toolJson: `${json_keys.SKELETON_RENDERING_JSON_KEY}.lineWidth${viewName}`, - isValid: (layer) => layer.hasSkeletonsLayer, - toolDescription: `Skeleton line width (${viewName})`, - title: `Skeleton line width (${viewName})`, - ...rangeLayerControl((layer) => ({ - value: - layer.displayState.skeletonRenderingOptions[ - `params${viewName}` as const - ].lineWidth, - options: { min: 1, max: 40, step: 1 }, - })), - }, - ]; + getMode: (layer: SegmentationUserLayer) => TrackableEnum, +): LayerControlDefinition { + return { + label: `Skeleton mode (${viewName})`, + toolJson: `${json_keys.SKELETON_RENDERING_JSON_KEY}.mode${viewName}`, + isValid: (layer) => layer.hasSkeletonsLayer, + ...enumLayerControl(getMode), + }; +} + +function getSkeletonLineWidthControl( + viewName: "2d" | "3d", +): LayerControlDefinition { + return { + label: `Line width (${viewName})`, + toolJson: `${json_keys.SKELETON_RENDERING_JSON_KEY}.lineWidth${viewName}`, + isValid: (layer) => layer.hasSkeletonsLayer, + toolDescription: `Skeleton line width (${viewName})`, + title: `Skeleton line width (${viewName})`, + ...rangeLayerControl((layer) => ({ + value: + layer.displayState.skeletonRenderingOptions[ + `params${viewName}` as const + ].lineWidth, + options: { min: 1, max: 40, step: 1 }, + })), + }; } export function registerLayerControls(layerType: typeof SegmentationUserLayer) { diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index f001663dd6..7a8af691d7 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -42,11 +42,16 @@ import type { SliceViewPanel } from "#src/sliceview/panel.js"; import type { SliceViewPanelRenderContext } from "#src/sliceview/renderlayer.js"; import { SliceViewPanelRenderLayer } from "#src/sliceview/renderlayer.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; -import { TrackableValue, WatchableValue } from "#src/trackable_value.js"; +import type { WatchableValueInterface } from "#src/trackable_value.js"; +import { + makeCachedDerivedWatchableValue, + TrackableValue, + WatchableValue, +} from "#src/trackable_value.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; import type { vec3 } from "#src/util/geom.js"; -import { mat4 } from "#src/util/geom.js"; +import { mat3, mat3FromMat4, mat4, scaleMat3Output } from "#src/util/geom.js"; import { verifyFinitePositiveFloat } from "#src/util/json.js"; import { NullarySignal } from "#src/util/signal.js"; import type { Trackable } from "#src/util/trackable.js"; @@ -55,7 +60,6 @@ import { TrackableEnum } from "#src/util/trackable_enum.js"; import { GLBuffer } from "#src/webgl/buffer.js"; import { defineCircleShader, - drawCircles, initializeCircleShader, } from "#src/webgl/circles.js"; import { glsl_COLORMAPS } from "#src/webgl/colormaps.js"; @@ -66,11 +70,15 @@ import { parameterizedEmitterDependentShaderGetter, shaderCodeWithLineDirective, } from "#src/webgl/dynamic_shader.js"; +import { defineLineShader, initializeLineShader } from "#src/webgl/lines.js"; +import { drawQuads } from "#src/webgl/quad.js"; +import { defineRaycastCylinderShader } from "#src/webgl/raycast_cylinder.js"; import { - defineLineShader, - drawLines, - initializeLineShader, -} from "#src/webgl/lines.js"; + glsl_raycastFragmentSetup, + initializeRaycastPrimitiveShader, + projectionMatrixShaderModule, +} from "#src/webgl/raycast_primitive.js"; +import { defineRaycastSphereShader } from "#src/webgl/raycast_sphere.js"; import type { ShaderBuilder, ShaderProgram, @@ -94,13 +102,42 @@ import { } from "#src/webgl/texture_access.js"; import { defineVertexId, VertexIdHelper } from "#src/webgl/vertex_id.js"; -const tempMat2 = mat4.create(); +const tempModelClip = mat4.create(); +const tempMat3 = mat3.create(); const DEFAULT_FRAGMENT_MAIN = `void main() { emitDefault(); } `; +export enum SkeletonRenderMode3d { + LINES = 0, + LINES_AND_POINTS = 1, + CYLINDERS = 2, + CYLINDERS_AND_SPHERES = 3, +} + +export enum SkeletonRenderMode2d { + LINES = SkeletonRenderMode3d.LINES, + LINES_AND_POINTS = SkeletonRenderMode3d.LINES_AND_POINTS, +} + +export type SkeletonRenderMode = SkeletonRenderMode2d | SkeletonRenderMode3d; + +function isRaycastMode(mode: SkeletonRenderMode) { + return ( + mode === SkeletonRenderMode3d.CYLINDERS || + mode === SkeletonRenderMode3d.CYLINDERS_AND_SPHERES + ); +} + +function hasEnlargedNodes(mode: SkeletonRenderMode) { + return ( + mode === SkeletonRenderMode3d.LINES_AND_POINTS || + mode === SkeletonRenderMode3d.CYLINDERS_AND_SPHERES + ); +} + interface VertexAttributeRenderInfo extends VertexAttributeInfo { name: string; webglDataType: number; @@ -120,15 +157,26 @@ class RenderHelper extends RefCounted { "vertexData", ); private vertexIdHelper; + private readonly raycastEnabled: WatchableValueInterface; get vertexAttributes(): VertexAttributeRenderInfo[] { return this.base.vertexAttributes; } defineCommonShader(builder: ShaderBuilder) { defineVertexId(builder); + builder.require(projectionMatrixShaderModule); builder.addUniform("highp vec4", "uColor"); - builder.addUniform("highp mat4", "uProjection"); builder.addUniform("highp uint", "uPickID"); + this.defineAttributeAccess(builder); + builder.addFragmentCode(` +vec4 segmentColor() { + return uColor; +} +`); + } + + get featherWidthInPixels() { + return this.targetIsSliceView ? 1.0 : 0.0; } edgeShaderGetter; @@ -141,46 +189,92 @@ class RenderHelper extends RefCounted { constructor( public base: SkeletonLayer, public targetIsSliceView: boolean, + renderOptions: ViewSpecificSkeletonRenderingOptions, ) { super(); this.vertexIdHelper = this.registerDisposer(VertexIdHelper.get(this.gl)); + this.raycastEnabled = this.registerDisposer( + makeCachedDerivedWatchableValue( + (mode: SkeletonRenderMode) => !targetIsSliceView && isRaycastMode(mode), + [renderOptions.mode], + ), + ); + const { displayState } = base; + + const sharedShaderOptions = { + fallbackParameters: base.fallbackShaderParameters, + parameters: + displayState.skeletonRenderingOptions.shaderControlState.builderState, + extraParameters: this.raycastEnabled, + shaderError: displayState.shaderError, + }; this.edgeShaderGetter = parameterizedEmitterDependentShaderGetter( this, this.gl, { + ...sharedShaderOptions, memoizeKey: { - type: "skeleton/SkeletonShaderManager/edge", + type: "skeleton/edge", vertexAttributes: this.vertexAttributes, }, - fallbackParameters: this.base.fallbackShaderParameters, - parameters: - this.base.displayState.skeletonRenderingOptions.shaderControlState - .builderState, - shaderError: this.base.displayState.shaderError, - defineShader: ( - builder: ShaderBuilder, - shaderBuilderState: ShaderControlsBuilderState, - ) => { - if (shaderBuilderState.parseResult.errors.length !== 0) { - throw new Error("Invalid UI control specification"); - } - this.defineCommonShader(builder); - this.defineAttributeAccess(builder); - defineLineShader(builder); - builder.addAttribute("highp uvec2", "aVertexIndex"); - builder.addUniform("highp float", "uLineWidth"); - let vertexMain = ` + defineShader: this.defineEdgeShader.bind(this), + }, + ); + this.nodeShaderGetter = parameterizedEmitterDependentShaderGetter( + this, + this.gl, + { + ...sharedShaderOptions, + memoizeKey: { + type: "skeleton/node", + vertexAttributes: this.vertexAttributes, + }, + defineShader: this.defineNodeShader.bind(this), + }, + ); + } + + private defineEdgeShader( + builder: ShaderBuilder, + shaderBuilderState: ShaderControlsBuilderState, + useRaycast: boolean, + ) { + this.defineCommonShader(builder); + builder.addAttribute("highp uvec2", "aVertexIndex"); + builder.addUniform("highp float", "uNodeClipPixelRadius"); + let vertexMain = ` highp vec3 vertexA = readAttribute0(aVertexIndex.x); highp vec3 vertexB = readAttribute0(aVertexIndex.y); -emitLine(uProjection, vertexA, vertexB, uLineWidth); +`; + if (useRaycast) { + defineRaycastCylinderShader(builder); + builder.addUniform("highp float", "uEdgePixelRadius"); + vertexMain += ` +highp uint vertexIndex = aVertexIndex.x; +highp float edgeRadius = + getRaycastModelRadiusForPixels(mix(vertexA, vertexB, 0.5), uEdgePixelRadius); +emitRaycastCylinder(vertexA, vertexB, edgeRadius, + getRaycastModelRadiusForPixels(vertexA, uNodeClipPixelRadius), + getRaycastModelRadiusForPixels(vertexB, uNodeClipPixelRadius)); +`; + builder.addFragmentCode(` +void emitRGB(vec3 color) { + emit(vec4(color * raycastLightingFactor * uColor.a, uColor.a), + raycastSurfaceDepth, uPickID); +} +void emitDefault() { + emitRGB(uColor.rgb); +} +`); + } else { + defineLineShader(builder, /*rounded=*/ false, /*endpointClipping=*/ true); + builder.addUniform("highp float", "uLineWidth"); + vertexMain += ` +emitLine(uProjection, vertexA, vertexB, uLineWidth, uNodeClipPixelRadius); highp uint lineEndpointIndex = getLineEndpointIndex(); highp uint vertexIndex = aVertexIndex.x * (1u - lineEndpointIndex) + aVertexIndex.y * lineEndpointIndex; `; - - builder.addFragmentCode(` -vec4 segmentColor() { - return uColor; -} + builder.addFragmentCode(` void emitRGB(vec3 color) { emit(vec4(color * uColor.a, uColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}), uPickID); } @@ -188,69 +282,49 @@ void emitDefault() { emit(vec4(uColor.rgb, uColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}), uPickID); } `); - builder.addFragmentCode(glsl_COLORMAPS); - const { vertexAttributes } = this; - const numAttributes = vertexAttributes.length; - for (let i = 1; i < numAttributes; ++i) { - const info = vertexAttributes[i]; - builder.addVarying(`highp ${info.glslDataType}`, `vCustom${i}`); - vertexMain += `vCustom${i} = readAttribute${i}(vertexIndex);\n`; - builder.addFragmentCode(`#define ${info.name} vCustom${i}\n`); - builder.addFragmentCode( - `#define prop_${info.name}() vCustom${i}\n`, - ); - } - builder.setVertexMain(vertexMain); - addControlsToBuilder(shaderBuilderState, builder); - builder.addFragmentCode(glsl_string); - builder.setFragmentMainFunction( - shaderCodeWithLineDirective(shaderBuilderState.parseResult.code), - ); - }, - }, + } + this.finalizeShaderBuilder( + builder, + shaderBuilderState, + vertexMain, + useRaycast, ); + } - this.nodeShaderGetter = parameterizedEmitterDependentShaderGetter( - this, - this.gl, - { - memoizeKey: { - type: "skeleton/SkeletonShaderManager/node", - vertexAttributes: this.vertexAttributes, - }, - fallbackParameters: this.base.fallbackShaderParameters, - parameters: - this.base.displayState.skeletonRenderingOptions.shaderControlState - .builderState, - shaderError: this.base.displayState.shaderError, - defineShader: ( - builder: ShaderBuilder, - shaderBuilderState: ShaderControlsBuilderState, - ) => { - if (shaderBuilderState.parseResult.errors.length !== 0) { - throw new Error("Invalid UI control specification"); - } - this.defineCommonShader(builder); - this.defineAttributeAccess(builder); - defineCircleShader( - builder, - /*crossSectionFade=*/ this.targetIsSliceView, - ); - builder.addUniform("highp float", "uNodeDiameter"); - let vertexMain = ` + private defineNodeShader( + builder: ShaderBuilder, + shaderBuilderState: ShaderControlsBuilderState, + useRaycast: boolean, + ) { + this.defineCommonShader(builder); + let vertexMain = ` highp uint vertexIndex = uint(gl_InstanceID); highp vec3 vertexPosition = readAttribute0(vertexIndex); -emitCircle(uProjection * vec4(vertexPosition, 1.0), uNodeDiameter, 0.0); `; - - builder.addFragmentCode(` -vec4 segmentColor() { - return uColor; + if (useRaycast) { + defineRaycastSphereShader(builder); + builder.addUniform("highp float", "uNodePixelRadius"); + vertexMain += `emitRaycastSphere( + vertexPosition, + getRaycastModelRadiusForPixels(vertexPosition, uNodePixelRadius)); +`; + builder.addFragmentCode(` +void emitRGBA(vec4 color) { + emit(vec4(color.rgb * raycastLightingFactor * color.a, color.a), + raycastSurfaceDepth, uPickID); } +`); + } else { + defineCircleShader(builder, /*crossSectionFade=*/ this.targetIsSliceView); + builder.addUniform("highp float", "uNodeDiameter"); + vertexMain += `emitCircle(uProjection * vec4(vertexPosition, 1.0), uNodeDiameter, 0.0);\n`; + builder.addFragmentCode(` void emitRGBA(vec4 color) { - vec4 borderColor = color; - emit(getCircleColor(color, borderColor), uPickID); + emit(getCircleColor(color, color), uPickID); } +`); + } + builder.addFragmentCode(` void emitRGB(vec3 color) { emitRGBA(vec4(color, 1.0)); } @@ -258,26 +332,42 @@ void emitDefault() { emitRGBA(uColor); } `); - builder.addFragmentCode(glsl_COLORMAPS); - const { vertexAttributes } = this; - const numAttributes = vertexAttributes.length; - for (let i = 1; i < numAttributes; ++i) { - const info = vertexAttributes[i]; - builder.addVarying(`highp ${info.glslDataType}`, `vCustom${i}`); - vertexMain += `vCustom${i} = readAttribute${i}(vertexIndex);\n`; - builder.addFragmentCode(`#define ${info.name} vCustom${i}\n`); - builder.addFragmentCode( - `#define prop_${info.name}() vCustom${i}\n`, - ); - } - builder.setVertexMain(vertexMain); - addControlsToBuilder(shaderBuilderState, builder); - builder.addFragmentCode(glsl_string); - builder.setFragmentMainFunction( - shaderCodeWithLineDirective(shaderBuilderState.parseResult.code), - ); - }, - }, + this.finalizeShaderBuilder( + builder, + shaderBuilderState, + vertexMain, + useRaycast, + ); + } + + private finalizeShaderBuilder( + builder: ShaderBuilder, + shaderBuilderState: ShaderControlsBuilderState, + vertexMain: string, + useRaycast: boolean, + ) { + if (shaderBuilderState.parseResult.errors.length !== 0) { + throw new Error("Invalid UI control specification"); + } + builder.addFragmentCode(glsl_COLORMAPS); + const { vertexAttributes } = this; + for (let i = 1; i < vertexAttributes.length; ++i) { + const info = vertexAttributes[i]; + builder.addVarying(`highp ${info.glslDataType}`, `vCustom${i}`); + vertexMain += `vCustom${i} = readAttribute${i}(vertexIndex);\n`; + builder.addFragmentCode(`#define ${info.name} vCustom${i}\n`); + builder.addFragmentCode(`#define prop_${info.name}() vCustom${i}\n`); + } + builder.setVertexMain(vertexMain); + addControlsToBuilder(shaderBuilderState, builder); + builder.addFragmentCode(glsl_string); + builder.addFragmentCode( + "void userMain();\n#define main userMain\n" + + shaderCodeWithLineDirective(shaderBuilderState.parseResult.code) + + "\n#undef main\n", + ); + builder.setFragmentMain( + (useRaycast ? glsl_raycastFragmentSetup : "") + "userMain();", ); } @@ -322,12 +412,94 @@ void emitDefault() { renderContext: SliceViewPanelRenderContext | PerspectiveViewRenderContext, modelMatrix: mat4, ) { - const { viewProjectionMat } = renderContext.projectionParameters; - const mat = mat4.multiply(tempMat2, viewProjectionMat, modelMatrix); - gl.uniformMatrix4fv(shader.uniform("uProjection"), false, mat); + const { projectionParameters } = renderContext; + const modelClip = mat4.multiply( + tempModelClip, + projectionParameters.viewProjectionMat, + modelMatrix, + ); + if (this.raycastEnabled.value) { + this.setRaycastUniforms( + gl, + shader, + renderContext, + modelMatrix, + modelClip, + ); + } else { + gl.uniformMatrix4fv(shader.uniform("uProjection"), false, modelClip); + } this.vertexIdHelper.enable(); } + private setRaycastUniforms( + gl: GL, + shader: ShaderProgram, + renderContext: SliceViewPanelRenderContext | PerspectiveViewRenderContext, + modelMatrix: mat4, + modelClip: mat4, + ) { + const { projectionParameters } = renderContext; + initializeRaycastPrimitiveShader(shader, modelClip, projectionParameters); + mat3FromMat4(tempMat3, modelMatrix); + scaleMat3Output( + tempMat3, + tempMat3, + projectionParameters.displayDimensionRenderInfo.canonicalVoxelFactors, + ); + mat3.invert(tempMat3, tempMat3); + mat3.transpose(tempMat3, tempMat3); + gl.uniformMatrix3fv(shader.uniform("uNormalTransform"), false, tempMat3); + const { lightDirection, ambientLighting, directionalLighting } = + renderContext as PerspectiveViewRenderContext; + gl.uniform4f( + shader.uniform("uLightDirection"), + lightDirection[0] * directionalLighting, + lightDirection[1] * directionalLighting, + lightDirection[2] * directionalLighting, + ambientLighting, + ); + } + + setEdgeSizeUniforms( + gl: GL, + shader: ShaderProgram, + projectionParameters: { width: number; height: number }, + lineWidth: number, + nodeDiameter: number, + ) { + gl.uniform1f( + shader.uniform("uNodeClipPixelRadius"), + nodeDiameter / 2 + this.featherWidthInPixels, + ); + if (this.raycastEnabled.value) { + gl.uniform1f(shader.uniform("uEdgePixelRadius"), lineWidth / 2); + } else { + initializeLineShader( + shader, + projectionParameters, + this.featherWidthInPixels, + ); + gl.uniform1f(shader.uniform("uLineWidth"), lineWidth); + } + } + + setNodeSizeUniforms( + gl: GL, + shader: ShaderProgram, + projectionParameters: { width: number; height: number }, + nodeDiameter: number, + ) { + if (this.raycastEnabled.value) { + gl.uniform1f(shader.uniform("uNodePixelRadius"), nodeDiameter / 2); + } else { + initializeCircleShader(shader, projectionParameters, { + featherWidthInPixels: this.featherWidthInPixels, + }); + gl.uniform1f(shader.uniform("uNodeDiameter"), nodeDiameter); + } + } + setColor(gl: GL, shader: ShaderProgram, color: vec3) { gl.uniform4fv(shader.uniform("uColor"), color); } @@ -341,7 +513,6 @@ void emitDefault() { edgeShader: ShaderProgram, nodeShader: ShaderProgram, skeletonChunk: SkeletonChunk, - projectionParameters: { width: number; height: number }, ) { const { vertexAttributes } = this; const numAttributes = vertexAttributes.length; @@ -357,7 +528,6 @@ void emitDefault() { ); } - // Draw edges { edgeShader.bind(); const aVertexIndex = edgeShader.attribute("aVertexIndex"); @@ -367,26 +537,14 @@ void emitDefault() { WebGL2RenderingContext.UNSIGNED_INT, ); gl.vertexAttribDivisor(aVertexIndex, 1); - initializeLineShader( - edgeShader, - projectionParameters, - this.targetIsSliceView ? 1.0 : 0.0, - ); - drawLines(gl, 1, skeletonChunk.numIndices / 2); + drawQuads(gl, 1, skeletonChunk.numIndices / 2); gl.vertexAttribDivisor(aVertexIndex, 0); gl.disableVertexAttribArray(aVertexIndex); } - // Draw nodes - this is performed also in line render mode - // so that there are no visible gaps between the edges - // as the point size is set to the line width - { - nodeShader.bind(); - initializeCircleShader(nodeShader, projectionParameters, { - featherWidthInPixels: this.targetIsSliceView ? 1.0 : 0.0, - }); - drawCircles(nodeShader.gl, 1, skeletonChunk.numVertices); - } + // Drawn in every render mode so that there are no visible gaps between edges. + nodeShader.bind(); + drawQuads(gl, 1, skeletonChunk.numVertices); } endLayer(gl: GL, shader: ShaderProgram) { @@ -403,17 +561,21 @@ void emitDefault() { } } -export enum SkeletonRenderMode { - LINES = 0, - LINES_AND_POINTS = 1, +export class TrackableSkeletonRenderMode2d extends TrackableEnum { + constructor( + value: SkeletonRenderMode2d, + defaultValue: SkeletonRenderMode2d = value, + ) { + super(SkeletonRenderMode2d, value, defaultValue); + } } -export class TrackableSkeletonRenderMode extends TrackableEnum { +export class TrackableSkeletonRenderMode3d extends TrackableEnum { constructor( - value: SkeletonRenderMode, - defaultValue: SkeletonRenderMode = value, + value: SkeletonRenderMode3d, + defaultValue: SkeletonRenderMode3d = value, ) { - super(SkeletonRenderMode, value, defaultValue); + super(SkeletonRenderMode3d, value, defaultValue); } } @@ -423,8 +585,10 @@ export class TrackableSkeletonLineWidth extends TrackableValue { } } -export interface ViewSpecificSkeletonRenderingOptions { - mode: TrackableSkeletonRenderMode; +export interface ViewSpecificSkeletonRenderingOptions< + Mode extends SkeletonRenderMode = SkeletonRenderMode, +> { + mode: TrackableEnum; lineWidth: TrackableSkeletonLineWidth; } @@ -437,12 +601,14 @@ export class SkeletonRenderingOptions implements Trackable { shader = makeTrackableFragmentMain(DEFAULT_FRAGMENT_MAIN); shaderControlState = new ShaderControlState(this.shader); hideInactiveShaderControls = new TrackableBoolean(false); - params2d: ViewSpecificSkeletonRenderingOptions = { - mode: new TrackableSkeletonRenderMode(SkeletonRenderMode.LINES_AND_POINTS), + params2d: ViewSpecificSkeletonRenderingOptions = { + mode: new TrackableSkeletonRenderMode2d( + SkeletonRenderMode2d.LINES_AND_POINTS, + ), lineWidth: new TrackableSkeletonLineWidth(2), }; - params3d: ViewSpecificSkeletonRenderingOptions = { - mode: new TrackableSkeletonRenderMode(SkeletonRenderMode.LINES), + params3d: ViewSpecificSkeletonRenderingOptions = { + mode: new TrackableSkeletonRenderMode3d(SkeletonRenderMode3d.LINES), lineWidth: new TrackableSkeletonLineWidth(1), }; @@ -563,12 +729,9 @@ export class SkeletonLayer extends RefCounted { attachment, ); if (modelMatrix === undefined) return; - let pointDiameter: number; - if (renderOptions.mode.value === SkeletonRenderMode.LINES_AND_POINTS) { - pointDiameter = Math.max(5, lineWidth * 2); - } else { - pointDiameter = lineWidth; - } + const nodeDiameter = hasEnlargedNodes(renderOptions.mode.value) + ? Math.max(5, lineWidth * 2) + : lineWidth; const edgeShaderResult = renderHelper.edgeShaderGetter( renderContext.emitter, @@ -586,6 +749,7 @@ export class SkeletonLayer extends RefCounted { } const { shaderControlState } = this.displayState.skeletonRenderingOptions; + const { projectionParameters } = renderContext; edgeShader.bind(); renderHelper.beginLayer(gl, edgeShader, renderContext, modelMatrix); @@ -595,11 +759,22 @@ export class SkeletonLayer extends RefCounted { shaderControlState, edgeShaderParameters.parseResult, ); - gl.uniform1f(edgeShader.uniform("uLineWidth"), lineWidth!); + renderHelper.setEdgeSizeUniforms( + gl, + edgeShader, + projectionParameters, + lineWidth, + nodeDiameter, + ); nodeShader.bind(); renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); - gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); + renderHelper.setNodeSizeUniforms( + gl, + nodeShader, + projectionParameters, + nodeDiameter, + ); setControlsInShader( gl, nodeShader, @@ -635,13 +810,7 @@ export class SkeletonLayer extends RefCounted { nodeShader.bind(); renderHelper.setPickID(gl, nodeShader, pickIndex); } - renderHelper.drawSkeleton( - gl, - edgeShader, - nodeShader, - skeleton, - renderContext.projectionParameters, - ); + renderHelper.drawSkeleton(gl, edgeShader, nodeShader, skeleton); }, ); renderHelper.endLayer(gl, edgeShader); @@ -681,8 +850,10 @@ export class PerspectiveViewSkeletonLayer extends PerspectiveViewRenderLayer { private renderOptions: ViewSpecificSkeletonRenderingOptions; constructor(public base: SkeletonLayer) { super(); - this.renderHelper = this.registerDisposer(new RenderHelper(base, false)); this.renderOptions = base.displayState.skeletonRenderingOptions.params3d; + this.renderHelper = this.registerDisposer( + new RenderHelper(base, false, this.renderOptions), + ); this.layerChunkProgressInfo = base.layerChunkProgressInfo; this.registerDisposer(base); @@ -734,8 +905,10 @@ export class SliceViewPanelSkeletonLayer extends SliceViewPanelRenderLayer { private renderOptions: ViewSpecificSkeletonRenderingOptions; constructor(public base: SkeletonLayer) { super(); - this.renderHelper = this.registerDisposer(new RenderHelper(base, true)); this.renderOptions = base.displayState.skeletonRenderingOptions.params2d; + this.renderHelper = this.registerDisposer( + new RenderHelper(base, true, this.renderOptions), + ); this.layerChunkProgressInfo = base.layerChunkProgressInfo; this.registerDisposer(base); const { renderOptions } = this; diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index d8125c0145..9ff396c901 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -36,9 +36,9 @@ import { glsl_getQuadVertexPosition } from "#src/webgl/quad.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; import { glsl_clipLineToDepthRange } from "#src/webgl/shader_lib.js"; -export const raycastProjectionUniform = (builder: ShaderBuilder) => { +export function projectionMatrixShaderModule(builder: ShaderBuilder) { builder.addUniform("highp mat4", "uProjection"); -}; +} const glsl_raycastPrimitiveFragmentUtil = ` struct RaycastRay { @@ -214,7 +214,7 @@ raycastLightingFactor = raycastHit.lightingFactor; `; export function defineRaycastPrimitiveCommon(builder: ShaderBuilder) { - builder.require(raycastProjectionUniform); + builder.require(projectionMatrixShaderModule); builder.addUniform("highp mat4", "uInvProjection"); builder.addUniform("highp mat3", "uNormalTransform"); builder.addUniform("highp vec4", "uLightDirection"); From a48f508ef1be909561cc40f1a747e8078b6f3a0b Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 24 Aug 2026 13:42:14 +0200 Subject: [PATCH 12/33] test(python): add new tests for cylinder rendering --- python/tests/skeleton_options_test.py | 79 ----------- python/tests/skeleton_rendering_test.py | 167 ++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 79 deletions(-) delete mode 100644 python/tests/skeleton_options_test.py create mode 100644 python/tests/skeleton_rendering_test.py diff --git a/python/tests/skeleton_options_test.py b/python/tests/skeleton_options_test.py deleted file mode 100644 index 0cabf55f9f..0000000000 --- a/python/tests/skeleton_options_test.py +++ /dev/null @@ -1,79 +0,0 @@ -# @license -# Copyright 2020 Google Inc. -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""Tests that skeleton rendering options can be controlled via ViewerState.""" - -import neuroglancer -import neuroglancer.skeleton -import numpy as np - -dimensions = neuroglancer.CoordinateSpace( - names=["x", "y", "z"], units="nm", scales=[1, 1, 1] -) - - -class SkeletonSource(neuroglancer.skeleton.SkeletonSource): - def __init__(self): - super().__init__(dimensions=dimensions) - - def get_skeleton(self, object_id): - return neuroglancer.skeleton.Skeleton( - vertex_positions=[[0, 0, 0]], - edges=[[0, 0]], - ) - - -def test_skeleton_options(webdriver): - with webdriver.viewer.txn() as s: - s.dimensions = dimensions - s.position = [0, 0, 0] - s.layout = "xy" - s.layers.append( - name="a", - layer=neuroglancer.SegmentationLayer( - source=SkeletonSource(), - segments=[1], - ), - ) - s.layers[0].skeleton_rendering.line_width2d = 100 - s.layers[0].skeleton_rendering.shader = """ -#uicontrol vec3 color color(default="white") -void main () { - emitRGB(color); -} -""" - s.layers[0].skeleton_rendering.shader_controls["color"] = "#f00" - s.show_axis_lines = False - screenshot = webdriver.viewer.screenshot(size=[10, 10]).screenshot - np.testing.assert_array_equal( - screenshot.image_pixels, - np.tile(np.array([255, 0, 0, 255], dtype=np.uint8), (10, 10, 1)), - ) - - with webdriver.viewer.txn() as s: - s.layout = "3d" - s.layers[0].skeleton_rendering.line_width3d = 100 - screenshot = webdriver.viewer.screenshot(size=[10, 10]).screenshot - np.testing.assert_array_equal( - screenshot.image_pixels, - np.tile(np.array([255, 0, 0, 255], dtype=np.uint8), (10, 10, 1)), - ) - - with webdriver.viewer.txn() as s: - s.layers[0].source[0].subsources["default"] = False - - screenshot = webdriver.viewer.screenshot(size=[10, 10]).screenshot - np.testing.assert_array_equal( - screenshot.image_pixels, - np.tile(np.array([0, 0, 0, 255], dtype=np.uint8), (10, 10, 1)), - ) diff --git a/python/tests/skeleton_rendering_test.py b/python/tests/skeleton_rendering_test.py new file mode 100644 index 0000000000..650f01d0b6 --- /dev/null +++ b/python/tests/skeleton_rendering_test.py @@ -0,0 +1,167 @@ +# @license +# Copyright 2020 Google Inc. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests that skeleton rendering can be controlled via ViewerState.""" + +import neuroglancer +import neuroglancer.skeleton +import numpy as np +import pytest + +dimensions = neuroglancer.CoordinateSpace( + names=["x", "y", "z"], units="nm", scales=[1, 1, 1] +) + +USER_SHADER = """ +#uicontrol vec3 color color(default="white") +void main () { + emitRGB(color); +} +""" + + +class SinglePointSkeletonSource(neuroglancer.skeleton.SkeletonSource): + def __init__(self): + super().__init__(dimensions=dimensions) + + def get_skeleton(self, object_id): + return neuroglancer.skeleton.Skeleton( + vertex_positions=[[0, 0, 0]], + edges=[[0, 0]], + ) + + +class TwoNodeSkeletonSource(neuroglancer.skeleton.SkeletonSource): + """One diagonal edge, so that a cylinder has a real axis to be oriented along.""" + + def __init__(self): + super().__init__(dimensions=dimensions) + + def get_skeleton(self, object_id): + return neuroglancer.skeleton.Skeleton( + vertex_positions=[[-20, -20, 0], [20, 20, 0]], + edges=[[0, 1]], + ) + + +def screenshot_pixels(webdriver, size): + return webdriver.viewer.screenshot(size=[size, size]).screenshot.image_pixels + + +def render_skeleton(webdriver, source, *, layout, line_width, size, mode=None): + """Draws one red skeleton on black and returns the screenshot pixels.""" + with webdriver.viewer.txn() as s: + s.dimensions = dimensions + s.position = [0, 0, 0] + s.layout = layout + s.projection_scale = 120 + s.cross_section_scale = 0.6 + # Otherwise grey in the slice view, and these tests read black as not drawn. + s.cross_section_background_color = "#000000" + s.show_axis_lines = False + s.show_scale_bar = False + s.layers.append( + name="a", + layer=neuroglancer.SegmentationLayer(source=source, segments=[1]), + ) + rendering = s.layers[0].skeleton_rendering + rendering.line_width2d = line_width + rendering.line_width3d = line_width + if mode is not None: + if layout == "3d": + rendering.mode3d = mode + else: + rendering.mode2d = mode + rendering.shader = USER_SHADER + rendering.shader_controls["color"] = "#f00" + return screenshot_pixels(webdriver, size) + + +def assert_solid_color(image, color): + np.testing.assert_array_equal( + image, np.tile(np.array(color, dtype=np.uint8), image.shape[:2] + (1,)) + ) + + +def test_skeleton_options(webdriver): + # A marker wider than the viewport, so the colour can be checked exactly. + image = render_skeleton( + webdriver, + SinglePointSkeletonSource(), + layout="xy", + line_width=100, + size=10, + ) + assert_solid_color(image, [255, 0, 0, 255]) + + with webdriver.viewer.txn() as s: + s.layout = "3d" + assert_solid_color(screenshot_pixels(webdriver, 10), [255, 0, 0, 255]) + + with webdriver.viewer.txn() as s: + s.layers[0].source[0].subsources["default"] = False + assert_solid_color(screenshot_pixels(webdriver, 10), [0, 0, 0, 255]) + + +@pytest.mark.parametrize( + "layout,mode", + [ + ("xy", "lines"), + ("xy", "lines_and_points"), + ("3d", "lines"), + ("3d", "lines_and_points"), + ("3d", "cylinders"), + ("3d", "cylinders_and_spheres"), + ], +) +def test_skeleton_render_mode(webdriver, layout, mode): + image = render_skeleton( + webdriver, + TwoNodeSkeletonSource(), + layout=layout, + line_width=10, + size=100, + mode=mode, + ) + red, green, blue = (image[..., i].astype(int) for i in range(3)) + # A pure red shader leaves the other channels untouched in every mode. + np.testing.assert_array_equal(green, 0) + np.testing.assert_array_equal(blue, 0) + drawn_red = red[red != 0] + assert len(drawn_red) > 200, "nothing recognisable was drawn" + if mode in ("cylinders", "cylinders_and_spheres"): + # Lit by the surface normal, so the red varies; a billboard would be flat. + assert drawn_red.min() < 250 + assert drawn_red.max() == 255 + elif layout == "3d": + # No feather outside the slice view, so every drawn pixel is the full colour. + np.testing.assert_array_equal(drawn_red, 255) + + +@pytest.mark.parametrize( + "plain,enlarged", + [("lines", "lines_and_points"), ("cylinders", "cylinders_and_spheres")], +) +def test_skeleton_enlarged_nodes_cover_more(webdriver, plain, enlarged): + def drawn_pixel_count(mode): + image = render_skeleton( + webdriver, + TwoNodeSkeletonSource(), + layout="3d", + line_width=10, + size=100, + mode=mode, + ) + return int((image[..., 0] != 0).sum()) + + assert drawn_pixel_count(enlarged) > drawn_pixel_count(plain) From 3acb3d34136a9fd946078cf1ea5c64371f6ab3af Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 24 Aug 2026 13:43:35 +0200 Subject: [PATCH 13/33] fix: restore usage of correct lines and circles draw APIs --- src/skeleton/frontend.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 7a8af691d7..9f148138e9 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -60,6 +60,7 @@ import { TrackableEnum } from "#src/util/trackable_enum.js"; import { GLBuffer } from "#src/webgl/buffer.js"; import { defineCircleShader, + drawCircles, initializeCircleShader, } from "#src/webgl/circles.js"; import { glsl_COLORMAPS } from "#src/webgl/colormaps.js"; @@ -70,7 +71,11 @@ import { parameterizedEmitterDependentShaderGetter, shaderCodeWithLineDirective, } from "#src/webgl/dynamic_shader.js"; -import { defineLineShader, initializeLineShader } from "#src/webgl/lines.js"; +import { + defineLineShader, + drawLines, + initializeLineShader, +} from "#src/webgl/lines.js"; import { drawQuads } from "#src/webgl/quad.js"; import { defineRaycastCylinderShader } from "#src/webgl/raycast_cylinder.js"; import { @@ -361,8 +366,10 @@ void emitDefault() { builder.setVertexMain(vertexMain); addControlsToBuilder(shaderBuilderState, builder); builder.addFragmentCode(glsl_string); + // Run our main before user main to discard early + builder.addFragmentCode("void userMain();\n"); builder.addFragmentCode( - "void userMain();\n#define main userMain\n" + + "\n#define main userMain\n" + shaderCodeWithLineDirective(shaderBuilderState.parseResult.code) + "\n#undef main\n", ); @@ -537,14 +544,23 @@ void emitDefault() { WebGL2RenderingContext.UNSIGNED_INT, ); gl.vertexAttribDivisor(aVertexIndex, 1); - drawQuads(gl, 1, skeletonChunk.numIndices / 2); + const numEdges = skeletonChunk.numIndices / 2; + if (this.raycastEnabled.value) { + drawQuads(gl, 1, numEdges); + } else { + drawLines(gl, 1, numEdges); + } gl.vertexAttribDivisor(aVertexIndex, 0); gl.disableVertexAttribArray(aVertexIndex); } // Drawn in every render mode so that there are no visible gaps between edges. nodeShader.bind(); - drawQuads(gl, 1, skeletonChunk.numVertices); + if (this.raycastEnabled.value) { + drawQuads(gl, 1, skeletonChunk.numVertices); + } else { + drawCircles(gl, 1, skeletonChunk.numVertices); + } } endLayer(gl: GL, shader: ShaderProgram) { From 7c9bb970f19b401ac6fb2ce6a67d1324a5046810 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 25 Aug 2026 00:30:11 +0200 Subject: [PATCH 14/33] fix: properly cull based on depth in primitives --- src/webgl/raycast_primitive.browser_test.ts | 110 +++++++++++++++++++- src/webgl/raycast_primitive.ts | 35 ++++++- 2 files changed, 142 insertions(+), 3 deletions(-) diff --git a/src/webgl/raycast_primitive.browser_test.ts b/src/webgl/raycast_primitive.browser_test.ts index 9ffaca578b..a18ea7a7c6 100644 --- a/src/webgl/raycast_primitive.browser_test.ts +++ b/src/webgl/raycast_primitive.browser_test.ts @@ -14,12 +14,19 @@ * limitations under the License. */ -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; +import { mat4 } from "#src/util/geom.js"; +import type { GL } from "#src/webgl/context.js"; +import { drawQuads } from "#src/webgl/quad.js"; import { defineRaycastCylinderShader } from "#src/webgl/raycast_cylinder.js"; -import { glsl_raycastFragmentSetup } from "#src/webgl/raycast_primitive.js"; +import { + glsl_raycastFragmentSetup, + initializeRaycastPrimitiveShader, +} from "#src/webgl/raycast_primitive.js"; import { defineRaycastSphereShader } from "#src/webgl/raycast_sphere.js"; import { ShaderBuilder } from "#src/webgl/shader.js"; import { webglTest } from "#src/webgl/testing.js"; +import { defineVertexId, VertexIdHelper } from "#src/webgl/vertex_id.js"; function buildShader( definePrimitive: (builder: ShaderBuilder) => void, @@ -42,6 +49,87 @@ void emitShaded() { }); } +// A camera at the model-space origin looking down -z, so a model z of -1 is one +// unit in front of the camera. +const COVERAGE_VIEWPORT_SIZE = 64; +const COVERAGE_NEAR_BOUND = 0.1; +const COVERAGE_FAR_BOUND = 20; + +// Fraction of the viewport that the bounding quad rasterises. The fragment +// shader writes unconditionally, so this measures the vertex stage: an +// out-of-range quad is counted here but discarded by the real shader, making it +// invisible to any test of the shaded result. +function measureQuadCoverage( + gl: GL, + definePrimitive: (builder: ShaderBuilder) => void, + emitPrimitive: string, +): number { + const size = COVERAGE_VIEWPORT_SIZE; + const builder = new ShaderBuilder(gl); + builder.addOutputBuffer("vec4", "out_color", 0); + defineVertexId(builder); + definePrimitive(builder); + builder.setVertexMain(emitPrimitive); + builder.setFragmentMain("out_color = vec4(1.0, 1.0, 1.0, 1.0);\n"); + const shader = builder.build(); + const vertexIdHelper = VertexIdHelper.get(gl); + try { + shader.bind(); + vertexIdHelper.enable(); + const projectionMatrix = mat4.perspective( + mat4.create(), + Math.PI / 4, + 1, + COVERAGE_NEAR_BOUND, + COVERAGE_FAR_BOUND, + ); + initializeRaycastPrimitiveShader(shader, projectionMatrix, { + width: size, + height: size, + }); + gl.viewport(0, 0, size, size); + gl.clearColor(0, 0, 0, 0); + gl.clear(WebGL2RenderingContext.COLOR_BUFFER_BIT); + drawQuads(gl, 1, 1); + const pixels = new Uint8Array(size * size * 4); + gl.readPixels( + 0, + 0, + size, + size, + WebGL2RenderingContext.RGBA, + WebGL2RenderingContext.UNSIGNED_BYTE, + pixels, + ); + let covered = 0; + for (let i = 0; i < size * size; ++i) { + if (pixels[i * 4] !== 0) ++covered; + } + return covered / (size * size); + } finally { + vertexIdHelper.disable(); + shader.dispose(); + } +} + +// `depth` is the model-space z, negative for in front of the camera. +function cylinderCoverage(gl: GL, depth: number) { + return measureQuadCoverage( + gl, + defineRaycastCylinderShader, + `emitRaycastCylinder(vec3(0.0, -0.3, ${depth.toFixed(4)}), + vec3(0.0, 0.3, ${depth.toFixed(4)}), 0.05, 0.0, 0.0);`, + ); +} + +function sphereCoverage(gl: GL, depth: number) { + return measureQuadCoverage( + gl, + defineRaycastSphereShader, + `emitRaycastSphere(vec3(0.0, 0.0, ${depth.toFixed(4)}), 0.05);`, + ); +} + describe("raycast primitives", () => { it("compiles the sphere shader", () => { buildShader( @@ -57,4 +145,22 @@ describe("raycast primitives", () => { getRaycastModelRadiusForPixels(vec3(0.0), 2.0), 1.0, 1.0);`, ); }); + + it("bounds a cylinder tightly, and culls one behind the camera", () => { + webglTest((gl) => { + const visible = cylinderCoverage(gl, -1); + expect(visible).toBeGreaterThan(0); + expect(visible).toBeLessThan(0.5); + expect(cylinderCoverage(gl, 1)).toBe(0); + }); + }); + + it("bounds a sphere tightly, and culls one behind the camera", () => { + webglTest((gl) => { + const visible = sphereCoverage(gl, -1); + expect(visible).toBeGreaterThan(0); + expect(visible).toBeLessThan(0.5); + expect(sphereCoverage(gl, 1)).toBe(0); + }); + }); }); diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index 9ff396c901..04af1d65f9 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -89,6 +89,20 @@ RaycastHit makeRaycastHit(highp vec3 surfacePoint, highp vec3 modelNormal) { } `; +// A box wholly outside the depth range can only produce hits that +// `glsl_raycastFragmentSetup` discards, so culling it costs no coverage. +const glsl_raycastDepthRangeCull = ` +highp vec2 raycastDepthPlaneDistances(highp vec4 clip) { + return vec2(clip.z + clip.w, clip.w - clip.z); +} +// Both distances are linear, so callers pass the maximum over the box corners: the +// larger base value plus the magnitude of each half-extent term. Negative form, so +// a non-finite value leaves the box unculled rather than dropping it. +bool raycastBoxOutsideDepthRange(highp vec2 maxDepthDistances) { + return maxDepthDistances.x < 0.0 || maxDepthDistances.y < 0.0; +} +`; + // Emits the screen-axis-aligned quad covering the model-space box // `center +/- halfExtent`. // @@ -106,6 +120,16 @@ void emitRaycastAabbQuad(highp vec3 center, highp vec3 halfExtent) { highp vec4 clipX = uProjection[0] * halfExtent.x; highp vec4 clipY = uProjection[1] * halfExtent.y; highp vec4 clipZ = uProjection[2] * halfExtent.z; + + if (raycastBoxOutsideDepthRange( + raycastDepthPlaneDistances(clipCenter) + + abs(raycastDepthPlaneDistances(clipX)) + + abs(raycastDepthPlaneDistances(clipY)) + + abs(raycastDepthPlaneDistances(clipZ)))) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + highp vec2 ndcMin = vec2(RAYCAST_OFFSCREEN_NDC); highp vec2 ndcMax = vec2(-RAYCAST_OFFSCREEN_NDC); highp float ndcNearZ = 1.0; @@ -147,12 +171,20 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, highp vec4 clipB = uProjection * vec4(endpointB, 1.0); highp vec4 clipVectorA = uProjection * vec4(radiusVectorA, 0.0); highp vec4 clipVectorB = uProjection * vec4(radiusVectorB, 0.0); + highp vec2 quadCoefficient = getQuadVertexPosition(vec2(-1.0), vec2(1.0)); + + if (raycastBoxOutsideDepthRange( + max(raycastDepthPlaneDistances(clipA), raycastDepthPlaneDistances(clipB)) + + abs(raycastDepthPlaneDistances(clipVectorA)) + + abs(raycastDepthPlaneDistances(clipVectorB)))) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } // Clips clipA and clipB in place, so everything below uses the clipped segment. bool clipped = clipLineToDepthRange(clipA, clipB); highp float minW = min(clipA.w, clipB.w) - abs(clipVectorA.w) - abs(clipVectorB.w); - highp vec2 quadCoefficient = getQuadVertexPosition(vec2(-1.0), vec2(1.0)); // Positive form, so a non-finite result falls back rather than proceeding. if (!(clipped && minW > 1e-4 * max(clipA.w, clipB.w))) { @@ -221,6 +253,7 @@ export function defineRaycastPrimitiveCommon(builder: ShaderBuilder) { builder.addUniform("highp vec2", "uViewportSize"); builder.addVertexCode(glsl_getQuadVertexPosition); builder.addVertexCode(glsl_clipLineToDepthRange); + builder.addVertexCode(glsl_raycastDepthRangeCull); builder.addVertexCode(glsl_raycastAabbQuad); builder.addVertexCode(glsl_raycastAxialObbQuad); builder.addVertexCode(glsl_raycastPrimitivePixelRadius); From efd932e0028c6661222dea427653caeb1064722c Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 27 Aug 2026 19:50:16 +0200 Subject: [PATCH 15/33] refactor: clarify operations based on drawings of the problem --- src/webgl/raycast_cylinder.ts | 59 +++++++++--------- src/webgl/raycast_intersect.ts | 110 +++++++++++++++++++++++++++++++++ src/webgl/raycast_primitive.ts | 9 +++ src/webgl/raycast_sphere.ts | 35 ++++------- 4 files changed, 161 insertions(+), 52 deletions(-) create mode 100644 src/webgl/raycast_intersect.ts diff --git a/src/webgl/raycast_cylinder.ts b/src/webgl/raycast_cylinder.ts index ada3b47774..685dca5641 100644 --- a/src/webgl/raycast_cylinder.ts +++ b/src/webgl/raycast_cylinder.ts @@ -38,10 +38,11 @@ * `emitRaycastAxialObbQuad` and hands the axis frame to the fragment stage in * `vCylinderAxis`. * - The quadratic becomes a ray/circle test in the plane perpendicular to a unit - * axis, in the same perpendicular-distance form as `raycast_sphere.ts`; see - * `intersectRaycastPrimitive` below. + * axis. That test is `intersectRaycastCircle` in `raycast_intersect.ts`, shared + * with `raycast_sphere.ts`. * - The end caps are dropped (skeleton joints are covered by spheres) and - * endpoint clipping is added. + * endpoint clipping is added. Without caps every hit lies on the lateral + * surface, so the normal never needs a special case. * - Returns depth and a lighting factor rather than a ray distance and normal. */ @@ -92,44 +93,44 @@ RaycastHit intersectRaycastPrimitive() { highp vec3 axisDirection = vCylinderAxis.xyz; highp float axisLength = vCylinderAxis.w; - // Split the ray about the unit axis. In the plane perpendicular to the axis the - // cylinder is only a circle of vCylinderRadius centred on the axis, so the same - // right-triangle test as raycast_sphere.ts finds the crossing; the axial parts - // then say whether that crossing lies between the two endpoints. - highp vec3 baseToOrigin = ray.origin - vCylinderEndpointA; - highp float directionAlongAxis = dot(axisDirection, ray.direction); - highp float originAlongAxis = dot(axisDirection, baseToOrigin); - highp vec3 directionInPlane = ray.direction - directionAlongAxis * axisDirection; - highp vec3 originInPlane = baseToOrigin - originAlongAxis * axisDirection; + // Splitting the ray about the unit axis turns the cylinder into two independent + // problems. Across the axis it is only a circle of vCylinderRadius, which fixes + // where the ray crosses the surface. Along the axis it is only the interval + // [0, axisLength] from endpoint A, which says whether that crossing is drawn. + AxialSplit originAboutAxis = + splitAboutAxis(ray.origin - vCylinderEndpointA, axisDirection); + AxialSplit directionAboutAxis = splitAboutAxis(ray.direction, axisDirection); // ray.direction is a unit vector, so this length is the sine of the angle between // the ray and the axis. It scales ray distance into in-plane distance, and is zero // exactly when the ray runs parallel to the axis, which never crosses the lateral - // surface. Guard that explicitly: directionInPlane is then the zero vector, and + // surface. Guard that explicitly: the in-plane part is then the zero vector, and // GLSL ES leaves 0.0 / 0.0 undefined rather than promising a NaN we could catch. - highp float sinAngleToAxis = length(directionInPlane); + highp float sinAngleToAxis = length(directionAboutAxis.inPlane); if (!(sinAngleToAxis > 0.0)) return raycastMiss(); - highp vec3 inPlaneDirection = directionInPlane / sinAngleToAxis; - highp float projectedDistance = dot(originInPlane, inPlaneDirection); - highp vec3 perpendicular = originInPlane - projectedDistance * inPlaneDirection; - highp float halfChordSquared = - vCylinderRadius * vCylinderRadius - dot(perpendicular, perpendicular); - // Comparisons are in positive form so that a non-finite value misses rather than - // slipping through. Defence only: GLSL ES guarantees nothing about NaN. - if (!(halfChordSquared >= 0.0)) return raycastMiss(); - highp float halfChord = sqrt(halfChordSquared); - // Only the near crossing is drawn, as in raycast_sphere.ts: a negative one means - // the cylinder is behind us or we are inside it, and both are a miss. - highp float hitDistance = (-projectedDistance - halfChord) / sinAngleToAxis; - if (!(hitDistance >= 0.0)) return raycastMiss(); + // Problem one, the circle. Its distance is measured in the plane, so divide by the + // sine to convert it back to a distance along the ray. + RaycastCircleHit circleHit = intersectRaycastCircle( + originAboutAxis.inPlane, directionAboutAxis.inPlane / sinAngleToAxis, + vCylinderRadius); + if (!circleHit.hit) return raycastMiss(); + highp float hitDistance = circleHit.distanceAlongRay / sinAngleToAxis; - highp float axialDistance = originAlongAxis + hitDistance * directionAlongAxis; + // Problem two, the interval. axialDistance is the distance from endpoint A to the + // hit point H, measured along the axis. + highp float axialDistance = + originAboutAxis.alongAxis + hitDistance * directionAboutAxis.alongAxis; if (!(axialDistance >= 0.0 && axialDistance <= axisLength)) return raycastMiss(); highp vec3 surfacePoint = ray.origin + hitDistance * ray.direction; if (cylinderPointClipped(surfacePoint)) return raycastMiss(); - return makeRaycastHit(surfacePoint, originInPlane + hitDistance * directionInPlane); + // The ends are open, so every hit lies on the lateral surface and the normal is + // always H - C. Here C is the axis point level with H, at endpoint A plus + // axialDistance along the axis, not the end centre itself. That offset is exactly + // what the circle test measured, so no separate normal is needed. A capped + // cylinder would need one: the two end discs face along the axis instead. + return makeRaycastHit(surfacePoint, circleHit.offsetFromCenter); } `); } diff --git a/src/webgl/raycast_intersect.ts b/src/webgl/raycast_intersect.ts new file mode 100644 index 0000000000..da6e0d3ccd --- /dev/null +++ b/src/webgl/raycast_intersect.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file Ray intersection GLSL shared by the raycast primitives. Pure geometry: + * nothing here refers to a uniform, to `RaycastRay`, or to `RaycastHit`. + * + * Naming follows the conventional letters for a ray/sphere test: + * + * C centre of the circle or sphere the ray is tested against + * R its radius + * D perpendicular distance from C to the ray + * H near hit point, where the ray first meets the surface + * N outward normal at H, equal to H - C + * + * `raycast_cylinder.ts` adds an axis and reuses the same test in the plane + * perpendicular to it; see `glsl_splitAboutAxis`. + */ + +// Both `raycast_sphere.ts` and `raycast_cylinder.ts` reduce to this one test. +// The sphere applies it in three dimensions; the cylinder applies it to the +// circular cross-section, with every vector confined to one plane. +export const glsl_intersectRaycastCircle = ` +struct RaycastCircleHit { + bool hit; + // Distance along unitDirection from the ray origin to the near hit point H. + highp float distanceAlongRay; + // N = H - C, the offset from the centre to the hit point. For a sphere, and for + // the lateral surface of a cylinder, this is the outward normal at H. It is not + // a unit vector: the caller normalises after the normal transform, which a + // non-uniform transform changes anyway, so normalising here would be discarded. + highp vec3 offsetFromCenter; +}; + +RaycastCircleHit raycastCircleMiss() { + RaycastCircleHit hit; + hit.hit = false; + return hit; +} + +// Nearest crossing of the ray \`centerToOrigin + t * unitDirection\` with the sphere +// of radius R about the centre C, where centerToOrigin is the ray origin measured +// from C. unitDirection must have unit length. When every vector lies in one plane +// this is a ray/circle test instead; the algebra does not change. +// +// Only the near crossing at t >= 0 is reported. A negative one means the surface is +// behind us or the origin is inside it, and drawing the far surface then fills the +// view when the camera clips inside the geometry. +RaycastCircleHit intersectRaycastCircle(highp vec3 centerToOrigin, + highp vec3 unitDirection, + highp float radius) { + // unitDirection has unit length, so this projection locates where the ray passes + // closest to C: at t = -projectedDistance, offset from C by the perpendicular D. + highp float projectedDistance = dot(centerToOrigin, unitDirection); + highp vec3 perpendicular = centerToOrigin - projectedDistance * unitDirection; + highp float radiusSquared = radius * radius; + highp float perpendicularDistanceSquared = dot(perpendicular, perpendicular); + + // D <= R, the hit test; a tangent at D == R counts as a hit. Comparisons are in + // positive form so that a non-finite value misses rather than slipping through. + // Defence only: GLSL ES guarantees nothing about NaN. + if (!(perpendicularDistanceSquared <= radiusSquared)) return raycastCircleMiss(); + + // The half-chord is the third side of a right triangle with hypotenuse R and leg + // D, so the two crossings are at -projectedDistance -/+ halfChord. + highp float halfChord = sqrt(radiusSquared - perpendicularDistanceSquared); + highp float distanceAlongRay = -projectedDistance - halfChord; + if (!(distanceAlongRay >= 0.0)) return raycastCircleMiss(); + + RaycastCircleHit hit; + hit.hit = true; + hit.distanceAlongRay = distanceAlongRay; + // N = H - C: the perpendicular, walked back along the ray by the half-chord. + // Built from the ray so that two large model coordinates never subtract. + hit.offsetFromCenter = perpendicular - halfChord * unitDirection; + return hit; +} +`; + +// Separates a vector into the part along an axis and the part across it, which is +// what turns a cylinder into an independent circle problem and interval problem. +export const glsl_splitAboutAxis = ` +struct AxialSplit { + // Signed component along the unit axis. + highp float alongAxis; + // The remainder, which lies in the plane perpendicular to the axis. + highp vec3 inPlane; +}; + +// unitAxis must have unit length. +AxialSplit splitAboutAxis(highp vec3 vectorToSplit, highp vec3 unitAxis) { + AxialSplit split; + split.alongAxis = dot(unitAxis, vectorToSplit); + split.inPlane = vectorToSplit - split.alongAxis * unitAxis; + return split; +} +`; diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index 04af1d65f9..9b39795e47 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -29,10 +29,17 @@ * and other fairly uniform geometries. * Use the axial OBB (oriented bounding box) for objects with one defined long * axis, like cylinders, capsules, cones, etc. + * + * The intersection maths that the primitives share lives in `raycast_intersect.ts`, + * which this module pulls into every raycast fragment shader. */ import { mat4 } from "#src/util/geom.js"; import { glsl_getQuadVertexPosition } from "#src/webgl/quad.js"; +import { + glsl_intersectRaycastCircle, + glsl_splitAboutAxis, +} from "#src/webgl/raycast_intersect.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; import { glsl_clipLineToDepthRange } from "#src/webgl/shader_lib.js"; @@ -258,6 +265,8 @@ export function defineRaycastPrimitiveCommon(builder: ShaderBuilder) { builder.addVertexCode(glsl_raycastAxialObbQuad); builder.addVertexCode(glsl_raycastPrimitivePixelRadius); builder.addFragmentCode(glsl_raycastPrimitiveFragmentUtil); + builder.addFragmentCode(glsl_intersectRaycastCircle); + builder.addFragmentCode(glsl_splitAboutAxis); } const tempInvProjection = mat4.create(); diff --git a/src/webgl/raycast_sphere.ts b/src/webgl/raycast_sphere.ts index 835e5a9193..f81bb6e81e 100644 --- a/src/webgl/raycast_sphere.ts +++ b/src/webgl/raycast_sphere.ts @@ -35,8 +35,10 @@ * - The bounding-quad vertex stage has no counterpart in the original, which * ray-marches a full-screen quad. * - The discriminant uses the perpendicular-distance rearrangement instead of - * `c = dot(oc, oc) - r * r`; see `intersectRaycastPrimitive` below. - * This is for better scaling as neuroglancer can have large depth range. + * `c = dot(oc, oc) - r * r`, and moves into the shared + * `intersectRaycastCircle` in `raycast_intersect.ts`, which `raycast_cylinder.ts` + * also calls. This is for better scaling as neuroglancer can have large depth + * range. * - Returns depth and a lighting factor rather than a ray distance. */ @@ -62,27 +64,14 @@ void emitRaycastSphere(highp vec3 center, highp float radius) { RaycastHit intersectRaycastPrimitive() { RaycastRay ray = getRaycastEyeRay(); - // ray.direction is a unit vector, so this projection locates where the ray passes - // closest to the centre: at t = -projectedDistance, offset by perpendicular. - // The half-chord is the third side of a right triangle with hypotenuse radius and - // leg perpendicular, so it exists only while halfChordSquared is non-negative; - // the two surface crossings are then at -projectedDistance -/+ halfChord. - highp vec3 centerToOrigin = ray.origin - vSphereCenter; - highp float projectedDistance = dot(centerToOrigin, ray.direction); - highp vec3 perpendicular = centerToOrigin - projectedDistance * ray.direction; - highp float halfChordSquared = - vSphereRadius * vSphereRadius - dot(perpendicular, perpendicular); - // Comparisons are in positive form so that a non-finite value misses rather than - // slipping through. Defence only: GLSL ES guarantees nothing about NaN. - if (!(halfChordSquared >= 0.0)) return raycastMiss(); // triangle cannot close - highp float halfChord = sqrt(halfChordSquared); - // Only the near crossing is drawn. A negative one means the sphere is behind us or - // the origin is inside it, and drawing the far surface then fills the view when the - // camera clips inside the geometry. - highp float hitDistance = -projectedDistance - halfChord; - if (!(hitDistance >= 0.0)) return raycastMiss(); - highp vec3 offsetFromCenter = centerToOrigin + hitDistance * ray.direction; - return makeRaycastHit(ray.origin + hitDistance * ray.direction, offsetFromCenter); + // A sphere is one ray/circle test about its centre C, and nothing else. The + // normal at the hit point H is the standard sphere normal H - C, which + // intersectRaycastCircle returns as offsetFromCenter. + RaycastCircleHit circleHit = intersectRaycastCircle( + ray.origin - vSphereCenter, ray.direction, vSphereRadius); + if (!circleHit.hit) return raycastMiss(); + return makeRaycastHit(ray.origin + circleHit.distanceAlongRay * ray.direction, + circleHit.offsetFromCenter); } `); } From 575f515a540c52c0ce22e20915e8e8769ce6043f Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 27 Aug 2026 19:59:37 +0200 Subject: [PATCH 16/33] perf: draw all edges then draw all nodes avoid program switch Should but perf improvements, which is helpful with the cylinder rendering --- src/skeleton/frontend.ts | 143 ++++++++++++++++++++++++--------------- 1 file changed, 90 insertions(+), 53 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 9f148138e9..b1ee4e5157 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -50,7 +50,6 @@ import { } from "#src/trackable_value.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; -import type { vec3 } from "#src/util/geom.js"; import { mat3, mat3FromMat4, mat4, scaleMat3Output } from "#src/util/geom.js"; import { verifyFinitePositiveFloat } from "#src/util/json.js"; import { NullarySignal } from "#src/util/signal.js"; @@ -162,6 +161,7 @@ class RenderHelper extends RefCounted { "vertexData", ); private vertexIdHelper; + private readonly clearedTextureUnits = new Set(); private readonly raycastEnabled: WatchableValueInterface; get vertexAttributes(): VertexAttributeRenderInfo[] { return this.base.vertexAttributes; @@ -507,7 +507,7 @@ void emitDefault() { } } - setColor(gl: GL, shader: ShaderProgram, color: vec3) { + setColor(gl: GL, shader: ShaderProgram, color: Float32Array) { gl.uniform4fv(shader.uniform("uColor"), color); } @@ -515,47 +515,66 @@ void emitDefault() { gl.uniform1ui(shader.uniform("uPickID"), pickID); } - drawSkeleton( + private bindVertexAttributeTextures( gl: GL, - edgeShader: ShaderProgram, - nodeShader: ShaderProgram, + shader: ShaderProgram, skeletonChunk: SkeletonChunk, ) { const { vertexAttributes } = this; - const numAttributes = vertexAttributes.length; const { vertexAttributeTextures } = skeletonChunk; - for (let i = 0; i < numAttributes; ++i) { - const textureUnit = + for ( + let i = 0, numAttributes = vertexAttributes.length; + i < numAttributes; + ++i + ) { + gl.activeTexture( WebGL2RenderingContext.TEXTURE0 + - edgeShader.textureUnit(vertexAttributeSamplerSymbols[i]); - gl.activeTexture(textureUnit); + shader.textureUnit(vertexAttributeSamplerSymbols[i]), + ); gl.bindTexture( WebGL2RenderingContext.TEXTURE_2D, vertexAttributeTextures[i], ); } + } - { - edgeShader.bind(); - const aVertexIndex = edgeShader.attribute("aVertexIndex"); - skeletonChunk.indexBuffer.bindToVertexAttribI( - aVertexIndex, - 2, - WebGL2RenderingContext.UNSIGNED_INT, - ); - gl.vertexAttribDivisor(aVertexIndex, 1); - const numEdges = skeletonChunk.numIndices / 2; - if (this.raycastEnabled.value) { - drawQuads(gl, 1, numEdges); - } else { - drawLines(gl, 1, numEdges); - } - gl.vertexAttribDivisor(aVertexIndex, 0); - gl.disableVertexAttribArray(aVertexIndex); + beginEdges(shader: ShaderProgram) { + const { gl } = this; + const aVertexIndex = shader.attribute("aVertexIndex"); + gl.vertexAttribDivisor(aVertexIndex, 1); + return aVertexIndex; + } + + drawEdges( + gl: GL, + shader: ShaderProgram, + aVertexIndex: number, + skeletonChunk: SkeletonChunk, + ) { + this.bindVertexAttributeTextures(gl, shader, skeletonChunk); + skeletonChunk.indexBuffer.bindToVertexAttribI( + aVertexIndex, + 2, + WebGL2RenderingContext.UNSIGNED_INT, + ); + const numEdges = skeletonChunk.numIndices / 2; + if (this.raycastEnabled.value) { + drawQuads(gl, 1, numEdges); + } else { + drawLines(gl, 1, numEdges); } + } - // Drawn in every render mode so that there are no visible gaps between edges. - nodeShader.bind(); + endEdges(aVertexIndex: number) { + const { gl } = this; + gl.vertexAttribDivisor(aVertexIndex, 0); + gl.disableVertexAttribArray(aVertexIndex); + } + + // Nodes are drawn in every render mode so that there are no visible gaps + // between edges. + drawNodes(gl: GL, shader: ShaderProgram, skeletonChunk: SkeletonChunk) { + this.bindVertexAttributeTextures(gl, shader, skeletonChunk); if (this.raycastEnabled.value) { drawQuads(gl, 1, skeletonChunk.numVertices); } else { @@ -563,15 +582,22 @@ void emitDefault() { } } - endLayer(gl: GL, shader: ShaderProgram) { - const { vertexAttributes } = this; + // Each shader assigns its own texture unit per attribute, so both are asked; + // in practice they agree, hence the dedup rather than one loop per shader. + endLayer(gl: GL, ...shaders: ShaderProgram[]) { + const { vertexAttributes, clearedTextureUnits } = this; const numAttributes = vertexAttributes.length; - for (let i = 0; i < numAttributes; ++i) { - const curTextureUnit = - shader.textureUnit(vertexAttributeSamplerSymbols[i]) + - WebGL2RenderingContext.TEXTURE0; - gl.activeTexture(curTextureUnit); - gl.bindTexture(gl.TEXTURE_2D, null); + clearedTextureUnits.clear(); + for (const shader of shaders) { + for (let i = 0; i < numAttributes; ++i) { + const textureUnit = shader.textureUnit( + vertexAttributeSamplerSymbols[i], + ); + if (clearedTextureUnits.has(textureUnit)) continue; + clearedTextureUnits.add(textureUnit); + gl.activeTexture(WebGL2RenderingContext.TEXTURE0 + textureUnit); + gl.bindTexture(gl.TEXTURE_2D, null); + } } this.vertexIdHelper.disable(); } @@ -734,7 +760,7 @@ export class SkeletonLayer extends RefCounted { >, ) { const lineWidth = renderOptions.lineWidth.value; - const { gl, source, displayState } = this; + const { gl, displayState } = this; if (displayState.objectAlpha.value <= 0.0) { // Skip drawing. return; @@ -782,6 +808,11 @@ export class SkeletonLayer extends RefCounted { lineWidth, nodeDiameter, ); + const aVertexIndex = renderHelper.beginEdges(edgeShader); + this.drawPass(layer, renderContext, renderHelper, edgeShader, (skeleton) => + renderHelper.drawEdges(gl, edgeShader, aVertexIndex, skeleton), + ); + renderHelper.endEdges(aVertexIndex); nodeShader.bind(); renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); @@ -797,39 +828,45 @@ export class SkeletonLayer extends RefCounted { shaderControlState, nodeShaderParameters.parseResult, ); + this.drawPass(layer, renderContext, renderHelper, nodeShader, (skeleton) => + renderHelper.drawNodes(gl, nodeShader, skeleton), + ); - const skeletons = source.chunks; + renderHelper.endLayer(gl, edgeShader, nodeShader); + } + // Each pass registers pick IDs again, so a segment ends up with one ID for its + // edges and another for its nodes. Both map to that segment, so picking is + // unaffected. + private drawPass( + layer: RenderLayer, + renderContext: SliceViewPanelRenderContext | PerspectiveViewRenderContext, + renderHelper: RenderHelper, + shader: ShaderProgram, + drawChunk: (skeleton: SkeletonChunk) => void, + ) { + const { gl, displayState } = this; + const skeletons = this.source.chunks; forEachVisibleSegmentToDraw( displayState, layer, renderContext.emitColor, renderContext.emitPickID ? renderContext.pickIDs : undefined, (objectId, color, pickIndex) => { - const key = getObjectKey(objectId); - const skeleton = skeletons.get(key); + const skeleton = skeletons.get(getObjectKey(objectId)); if ( skeleton === undefined || skeleton.state !== ChunkState.GPU_MEMORY ) { return; } - if (color !== undefined) { - edgeShader.bind(); - renderHelper.setColor(gl, edgeShader, (color)); - nodeShader.bind(); - renderHelper.setColor(gl, nodeShader, (color)); - } + if (color !== undefined) renderHelper.setColor(gl, shader, color); if (pickIndex !== undefined) { - edgeShader.bind(); - renderHelper.setPickID(gl, edgeShader, pickIndex); - nodeShader.bind(); - renderHelper.setPickID(gl, nodeShader, pickIndex); + renderHelper.setPickID(gl, shader, pickIndex); } - renderHelper.drawSkeleton(gl, edgeShader, nodeShader, skeleton); + drawChunk(skeleton); }, ); - renderHelper.endLayer(gl, edgeShader); } isReady() { From 88521308d4240f092370f9ce2b247dd6f9e5af32 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 28 Aug 2026 15:00:22 +0200 Subject: [PATCH 17/33] refactor: clarify comments and namings Also combines the rendering tests together --- python/tests/skeleton_rendering_test.py | 96 ++++++++++----------- src/skeleton/frontend.ts | 35 +++----- src/webgl/lines.ts | 5 +- src/webgl/raycast_cylinder.ts | 82 +++++------------- src/webgl/raycast_intersect.ts | 110 ------------------------ src/webgl/raycast_primitive.ts | 90 +++++++++++-------- src/webgl/raycast_shader_lib.ts | 92 ++++++++++++++++++++ src/webgl/raycast_sphere.ts | 41 ++------- 8 files changed, 237 insertions(+), 314 deletions(-) delete mode 100644 src/webgl/raycast_intersect.ts create mode 100644 src/webgl/raycast_shader_lib.ts diff --git a/python/tests/skeleton_rendering_test.py b/python/tests/skeleton_rendering_test.py index 650f01d0b6..7748f6c419 100644 --- a/python/tests/skeleton_rendering_test.py +++ b/python/tests/skeleton_rendering_test.py @@ -16,7 +16,6 @@ import neuroglancer import neuroglancer.skeleton import numpy as np -import pytest dimensions = neuroglancer.CoordinateSpace( names=["x", "y", "z"], units="nm", scales=[1, 1, 1] @@ -42,8 +41,6 @@ def get_skeleton(self, object_id): class TwoNodeSkeletonSource(neuroglancer.skeleton.SkeletonSource): - """One diagonal edge, so that a cylinder has a real axis to be oriented along.""" - def __init__(self): super().__init__(dimensions=dimensions) @@ -70,6 +67,7 @@ def render_skeleton(webdriver, source, *, layout, line_width, size, mode=None): s.cross_section_background_color = "#000000" s.show_axis_lines = False s.show_scale_bar = False + s.layers.clear() s.layers.append( name="a", layer=neuroglancer.SegmentationLayer(source=source, segments=[1]), @@ -113,55 +111,57 @@ def test_skeleton_options(webdriver): assert_solid_color(screenshot_pixels(webdriver, 10), [0, 0, 0, 255]) -@pytest.mark.parametrize( - "layout,mode", - [ - ("xy", "lines"), - ("xy", "lines_and_points"), - ("3d", "lines"), - ("3d", "lines_and_points"), - ("3d", "cylinders"), - ("3d", "cylinders_and_spheres"), - ], -) -def test_skeleton_render_mode(webdriver, layout, mode): - image = render_skeleton( - webdriver, - TwoNodeSkeletonSource(), - layout=layout, - line_width=10, - size=100, - mode=mode, - ) - red, green, blue = (image[..., i].astype(int) for i in range(3)) - # A pure red shader leaves the other channels untouched in every mode. - np.testing.assert_array_equal(green, 0) - np.testing.assert_array_equal(blue, 0) - drawn_red = red[red != 0] - assert len(drawn_red) > 200, "nothing recognisable was drawn" - if mode in ("cylinders", "cylinders_and_spheres"): - # Lit by the surface normal, so the red varies; a billboard would be flat. - assert drawn_red.min() < 250 - assert drawn_red.max() == 255 - elif layout == "3d": - # No feather outside the slice view, so every drawn pixel is the full colour. - np.testing.assert_array_equal(drawn_red, 255) - - -@pytest.mark.parametrize( - "plain,enlarged", - [("lines", "lines_and_points"), ("cylinders", "cylinders_and_spheres")], -) -def test_skeleton_enlarged_nodes_cover_more(webdriver, plain, enlarged): - def drawn_pixel_count(mode): +# Each entry pairs a mode with the only shading signature it produces. +FEATHERED = "feathered" # slice view feathers the line edge +FLAT = "flat" # no feather outside the slice view +LIT = "lit" # shaded by the surface normal + +RENDER_MODES = [ + ("xy", "lines", FEATHERED), + ("xy", "lines_and_points", FEATHERED), + ("3d", "lines", FLAT), + ("3d", "lines_and_points", FLAT), + ("3d", "cylinders", LIT), + ("3d", "cylinders_and_spheres", LIT), +] + +ENLARGED_PAIRS = [ + ("xy", "lines", "lines_and_points"), + ("3d", "lines", "lines_and_points"), + ("3d", "cylinders", "cylinders_and_spheres"), +] + + +def test_skeleton_render_mode(webdriver): + drawn_counts = {} + for layout, mode, shading in RENDER_MODES: + case = f"{layout}/{mode}" image = render_skeleton( webdriver, TwoNodeSkeletonSource(), - layout="3d", + layout=layout, line_width=10, size=100, mode=mode, ) - return int((image[..., 0] != 0).sum()) - - assert drawn_pixel_count(enlarged) > drawn_pixel_count(plain) + red, green, blue = (image[..., i].astype(int) for i in range(3)) + # A pure red shader leaves the other channels untouched in every mode. + np.testing.assert_array_equal(green, 0, err_msg=case) + np.testing.assert_array_equal(blue, 0, err_msg=case) + drawn_red = red[red != 0] + assert len(drawn_red) > 200, f"{case} drew nothing recognisable" + drawn_counts[(layout, mode)] = len(drawn_red) + + if shading is LIT: + assert drawn_red.min() < 250, f"{case} is flat, so it is not lit" + assert drawn_red.max() == 255, case + elif shading is FLAT: + np.testing.assert_array_equal(drawn_red, 255, err_msg=case) + else: + assert drawn_red.min() < 255, f"{case} has no feathered edge" + assert drawn_red.max() == 255, case + + for layout, plain, enlarged in ENLARGED_PAIRS: + assert drawn_counts[(layout, enlarged)] > drawn_counts[(layout, plain)], ( + f"{layout}/{enlarged} covers no more than {layout}/{plain}" + ) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index b1ee4e5157..076df8f364 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -161,7 +161,6 @@ class RenderHelper extends RefCounted { "vertexData", ); private vertexIdHelper; - private readonly clearedTextureUnits = new Set(); private readonly raycastEnabled: WatchableValueInterface; get vertexAttributes(): VertexAttributeRenderInfo[] { return this.base.vertexAttributes; @@ -325,7 +324,8 @@ void emitRGBA(vec4 color) { vertexMain += `emitCircle(uProjection * vec4(vertexPosition, 1.0), uNodeDiameter, 0.0);\n`; builder.addFragmentCode(` void emitRGBA(vec4 color) { - emit(getCircleColor(color, color), uPickID); + vec4 borderColor = color; + emit(getCircleColor(color, borderColor), uPickID); } `); } @@ -521,16 +521,13 @@ void emitDefault() { skeletonChunk: SkeletonChunk, ) { const { vertexAttributes } = this; + const numAttributes = vertexAttributes.length; const { vertexAttributeTextures } = skeletonChunk; - for ( - let i = 0, numAttributes = vertexAttributes.length; - i < numAttributes; - ++i - ) { - gl.activeTexture( + for (let i = 0; i < numAttributes; ++i) { + const textureUnit = WebGL2RenderingContext.TEXTURE0 + - shader.textureUnit(vertexAttributeSamplerSymbols[i]), - ); + shader.textureUnit(vertexAttributeSamplerSymbols[i]); + gl.activeTexture(textureUnit); gl.bindTexture( WebGL2RenderingContext.TEXTURE_2D, vertexAttributeTextures[i], @@ -582,20 +579,15 @@ void emitDefault() { } } - // Each shader assigns its own texture unit per attribute, so both are asked; - // in practice they agree, hence the dedup rather than one loop per shader. endLayer(gl: GL, ...shaders: ShaderProgram[]) { - const { vertexAttributes, clearedTextureUnits } = this; + const { vertexAttributes } = this; const numAttributes = vertexAttributes.length; - clearedTextureUnits.clear(); for (const shader of shaders) { for (let i = 0; i < numAttributes; ++i) { - const textureUnit = shader.textureUnit( - vertexAttributeSamplerSymbols[i], - ); - if (clearedTextureUnits.has(textureUnit)) continue; - clearedTextureUnits.add(textureUnit); - gl.activeTexture(WebGL2RenderingContext.TEXTURE0 + textureUnit); + const textureUnit = + shader.textureUnit(vertexAttributeSamplerSymbols[i]) + + WebGL2RenderingContext.TEXTURE0; + gl.activeTexture(textureUnit); gl.bindTexture(gl.TEXTURE_2D, null); } } @@ -853,7 +845,8 @@ export class SkeletonLayer extends RefCounted { renderContext.emitColor, renderContext.emitPickID ? renderContext.pickIDs : undefined, (objectId, color, pickIndex) => { - const skeleton = skeletons.get(getObjectKey(objectId)); + const key = getObjectKey(objectId); + const skeleton = skeletons.get(key); if ( skeleton === undefined || skeleton.state !== ChunkState.GPU_MEMORY diff --git a/src/webgl/lines.ts b/src/webgl/lines.ts index 09313fde44..9a939701a2 100644 --- a/src/webgl/lines.ts +++ b/src/webgl/lines.ts @@ -29,8 +29,9 @@ import { glsl_clipLineToDepthRange } from "#src/webgl/shader_lib.js"; export const VERTICES_PER_LINE = VERTICES_PER_QUAD; /** - @param rounded adds a float borderWidth param to emitLine - @param endpointClipping adds a float endpointClipping param to emitLine + * @param rounded adds a borderWidth argument to emitLine. + * @param endpointClipping adds an endpointClipRadiusInPixels argument to emitLine, + * and discards fragments within that radius of either endpoint. */ export function defineLineShader( builder: ShaderBuilder, diff --git a/src/webgl/raycast_cylinder.ts b/src/webgl/raycast_cylinder.ts index 685dca5641..2fdfb029a7 100644 --- a/src/webgl/raycast_cylinder.ts +++ b/src/webgl/raycast_cylinder.ts @@ -15,35 +15,11 @@ */ /** - * @file Raycast open-ended cylinder drawn on a camera-facing quad; see - * `raycast_primitive.ts` for the shared conventions. + * @file Raycast cylinder drawn on a camera-facing quad. The vertex stage bounds the + * cylinder with a quad and the fragment stage returns depth and a lighting factor. * - * Adapted from Inigo Quilez's cylinder intersector - * (https://iquilezles.org/articles/intersectors/, - * https://www.shadertoy.com/view/4lcSRn), MIT licensed: - * - * The MIT License. Copyright (c) 2016 Inigo Quilez. - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to - * deal in the Software without restriction, including without limitation the - * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or - * sell copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: the above copyright - * notice and this permission notice shall be included in all copies or - * substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS". - * - * Modifications: - * - The vertex stage has no counterpart in the original, which ray-marches a - * full-screen quad. It bounds the cylinder with - * `emitRaycastAxialObbQuad` and hands the axis frame to the fragment stage in - * `vCylinderAxis`. - * - The quadratic becomes a ray/circle test in the plane perpendicular to a unit - * axis. That test is `intersectRaycastCircle` in `raycast_intersect.ts`, shared - * with `raycast_sphere.ts`. - * - The end caps are dropped (skeleton joints are covered by spheres) and - * endpoint clipping is added. Without caps every hit lies on the lateral - * surface, so the normal never needs a special case. - * - Returns depth and a lighting factor rather than a ray distance and normal. + * The ends are open, because skeleton joints are drawn as spheres. Each end also + * takes a clip radius, which removes the part of the surface that the joint covers. */ import { defineRaycastPrimitiveCommon } from "#src/webgl/raycast_primitive.js"; @@ -72,7 +48,7 @@ void emitRaycastCylinder(highp vec3 endpointA, highp vec3 endpointB, highp vec3 axisDirection = axisLength > 1e-6 ? axisVector / axisLength : vec3(0.0, 1.0, 0.0); vCylinderAxis = vec4(axisDirection, axisLength); - // Find two perpendicular radius vectors spanning the circular cross-section. + // Two perpendicular radius vectors spanning the circular cross-section. highp vec3 offAxisVector = abs(axisDirection.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0); highp vec3 radiusVectorA = normalize(cross(offAxisVector, axisDirection)) * radius; @@ -89,48 +65,36 @@ bool cylinderPointClipped(highp vec3 surfacePoint) { } RaycastHit intersectRaycastPrimitive() { - RaycastRay ray = getRaycastEyeRay(); + RaycastRay ray = getModelRayThroughFragment(); highp vec3 axisDirection = vCylinderAxis.xyz; highp float axisLength = vCylinderAxis.w; - // Splitting the ray about the unit axis turns the cylinder into two independent - // problems. Across the axis it is only a circle of vCylinderRadius, which fixes - // where the ray crosses the surface. Along the axis it is only the interval - // [0, axisLength] from endpoint A, which says whether that crossing is drawn. - AxialSplit originAboutAxis = - splitAboutAxis(ray.origin - vCylinderEndpointA, axisDirection); - AxialSplit directionAboutAxis = splitAboutAxis(ray.direction, axisDirection); + VectorSplit originSplit = + splitAlongDirection(ray.origin - vCylinderEndpointA, axisDirection); + VectorSplit directionSplit = splitAlongDirection(ray.direction, axisDirection); - // ray.direction is a unit vector, so this length is the sine of the angle between - // the ray and the axis. It scales ray distance into in-plane distance, and is zero - // exactly when the ray runs parallel to the axis, which never crosses the lateral - // surface. Guard that explicitly: the in-plane part is then the zero vector, and - // GLSL ES leaves 0.0 / 0.0 undefined rather than promising a NaN we could catch. - highp float sinAngleToAxis = length(directionAboutAxis.inPlane); + // Zero when the ray runs parallel to the axis, which never meets the lateral + // surface. GLSL ES leaves 0.0 / 0.0 undefined, so reject it before the divide. + highp float sinAngleToAxis = length(directionSplit.perpendicular); if (!(sinAngleToAxis > 0.0)) return raycastMiss(); - // Problem one, the circle. Its distance is measured in the plane, so divide by the - // sine to convert it back to a distance along the ray. + // Step 1. Across the axis the cylinder is a circle. The distance comes back in + // that plane, so scale it onto the ray. RaycastCircleHit circleHit = intersectRaycastCircle( - originAboutAxis.inPlane, directionAboutAxis.inPlane / sinAngleToAxis, + originSplit.perpendicular, directionSplit.perpendicular / sinAngleToAxis, vCylinderRadius); if (!circleHit.hit) return raycastMiss(); - highp float hitDistance = circleHit.distanceAlongRay / sinAngleToAxis; + highp float hitDist = circleHit.distAlongRay / sinAngleToAxis; - // Problem two, the interval. axialDistance is the distance from endpoint A to the - // hit point H, measured along the axis. - highp float axialDistance = - originAboutAxis.alongAxis + hitDistance * directionAboutAxis.alongAxis; - if (!(axialDistance >= 0.0 && axialDistance <= axisLength)) return raycastMiss(); - highp vec3 surfacePoint = ray.origin + hitDistance * ray.direction; + // Step 2. Along the axis it is an interval. + highp float axialDist = + originSplit.parallelDist + hitDist * directionSplit.parallelDist; + if (!(axialDist >= 0.0 && axialDist <= axisLength)) return raycastMiss(); + highp vec3 surfacePoint = ray.origin + hitDist * ray.direction; if (cylinderPointClipped(surfacePoint)) return raycastMiss(); - // The ends are open, so every hit lies on the lateral surface and the normal is - // always H - C. Here C is the axis point level with H, at endpoint A plus - // axialDistance along the axis, not the end centre itself. That offset is exactly - // what the circle test measured, so no separate normal is needed. A capped - // cylinder would need one: the two end discs face along the axis instead. - return makeRaycastHit(surfacePoint, circleHit.offsetFromCenter); + // Open ends, so the circle normal holds everywhere. Caps would not. + return makeRaycastHit(surfacePoint, circleHit.normal); } `); } diff --git a/src/webgl/raycast_intersect.ts b/src/webgl/raycast_intersect.ts deleted file mode 100644 index da6e0d3ccd..0000000000 --- a/src/webgl/raycast_intersect.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @license - * Copyright 2026 Google Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * @file Ray intersection GLSL shared by the raycast primitives. Pure geometry: - * nothing here refers to a uniform, to `RaycastRay`, or to `RaycastHit`. - * - * Naming follows the conventional letters for a ray/sphere test: - * - * C centre of the circle or sphere the ray is tested against - * R its radius - * D perpendicular distance from C to the ray - * H near hit point, where the ray first meets the surface - * N outward normal at H, equal to H - C - * - * `raycast_cylinder.ts` adds an axis and reuses the same test in the plane - * perpendicular to it; see `glsl_splitAboutAxis`. - */ - -// Both `raycast_sphere.ts` and `raycast_cylinder.ts` reduce to this one test. -// The sphere applies it in three dimensions; the cylinder applies it to the -// circular cross-section, with every vector confined to one plane. -export const glsl_intersectRaycastCircle = ` -struct RaycastCircleHit { - bool hit; - // Distance along unitDirection from the ray origin to the near hit point H. - highp float distanceAlongRay; - // N = H - C, the offset from the centre to the hit point. For a sphere, and for - // the lateral surface of a cylinder, this is the outward normal at H. It is not - // a unit vector: the caller normalises after the normal transform, which a - // non-uniform transform changes anyway, so normalising here would be discarded. - highp vec3 offsetFromCenter; -}; - -RaycastCircleHit raycastCircleMiss() { - RaycastCircleHit hit; - hit.hit = false; - return hit; -} - -// Nearest crossing of the ray \`centerToOrigin + t * unitDirection\` with the sphere -// of radius R about the centre C, where centerToOrigin is the ray origin measured -// from C. unitDirection must have unit length. When every vector lies in one plane -// this is a ray/circle test instead; the algebra does not change. -// -// Only the near crossing at t >= 0 is reported. A negative one means the surface is -// behind us or the origin is inside it, and drawing the far surface then fills the -// view when the camera clips inside the geometry. -RaycastCircleHit intersectRaycastCircle(highp vec3 centerToOrigin, - highp vec3 unitDirection, - highp float radius) { - // unitDirection has unit length, so this projection locates where the ray passes - // closest to C: at t = -projectedDistance, offset from C by the perpendicular D. - highp float projectedDistance = dot(centerToOrigin, unitDirection); - highp vec3 perpendicular = centerToOrigin - projectedDistance * unitDirection; - highp float radiusSquared = radius * radius; - highp float perpendicularDistanceSquared = dot(perpendicular, perpendicular); - - // D <= R, the hit test; a tangent at D == R counts as a hit. Comparisons are in - // positive form so that a non-finite value misses rather than slipping through. - // Defence only: GLSL ES guarantees nothing about NaN. - if (!(perpendicularDistanceSquared <= radiusSquared)) return raycastCircleMiss(); - - // The half-chord is the third side of a right triangle with hypotenuse R and leg - // D, so the two crossings are at -projectedDistance -/+ halfChord. - highp float halfChord = sqrt(radiusSquared - perpendicularDistanceSquared); - highp float distanceAlongRay = -projectedDistance - halfChord; - if (!(distanceAlongRay >= 0.0)) return raycastCircleMiss(); - - RaycastCircleHit hit; - hit.hit = true; - hit.distanceAlongRay = distanceAlongRay; - // N = H - C: the perpendicular, walked back along the ray by the half-chord. - // Built from the ray so that two large model coordinates never subtract. - hit.offsetFromCenter = perpendicular - halfChord * unitDirection; - return hit; -} -`; - -// Separates a vector into the part along an axis and the part across it, which is -// what turns a cylinder into an independent circle problem and interval problem. -export const glsl_splitAboutAxis = ` -struct AxialSplit { - // Signed component along the unit axis. - highp float alongAxis; - // The remainder, which lies in the plane perpendicular to the axis. - highp vec3 inPlane; -}; - -// unitAxis must have unit length. -AxialSplit splitAboutAxis(highp vec3 vectorToSplit, highp vec3 unitAxis) { - AxialSplit split; - split.alongAxis = dot(unitAxis, vectorToSplit); - split.inPlane = vectorToSplit - split.alongAxis * unitAxis; - return split; -} -`; diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index 9b39795e47..f8183f6b3b 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -29,17 +29,14 @@ * and other fairly uniform geometries. * Use the axial OBB (oriented bounding box) for objects with one defined long * axis, like cylinders, capsules, cones, etc. - * - * The intersection maths that the primitives share lives in `raycast_intersect.ts`, - * which this module pulls into every raycast fragment shader. */ import { mat4 } from "#src/util/geom.js"; import { glsl_getQuadVertexPosition } from "#src/webgl/quad.js"; import { glsl_intersectRaycastCircle, - glsl_splitAboutAxis, -} from "#src/webgl/raycast_intersect.js"; + glsl_splitAlongDirection, +} from "#src/webgl/raycast_shader_lib.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; import { glsl_clipLineToDepthRange } from "#src/webgl/shader_lib.js"; @@ -61,7 +58,7 @@ struct RaycastHit { highp float raycastSurfaceDepth = 0.0; highp float raycastLightingFactor = 1.0; -RaycastRay getRaycastEyeRay() { +RaycastRay getModelRayThroughFragment() { highp vec2 ndc = (gl_FragCoord.xy / uViewportSize) * 2.0 - 1.0; highp vec4 nearClip = uInvProjection * vec4(ndc, -1.0, 1.0); highp vec4 farClip = uInvProjection * vec4(ndc, 1.0, 1.0); @@ -73,11 +70,10 @@ RaycastRay getRaycastEyeRay() { return ray; } highp float getRaycastWindowDepth(highp vec3 modelPoint) { - // Assumes the default depth range [0, 1] and NDC z in [-1, 1]. highp vec4 clip = uProjection * vec4(modelPoint, 1.0); return 0.5 * (clip.z / clip.w) + 0.5; } -// modelNormal can be non-normalized. +// modelNormal need not be normalised. highp float getRaycastSurfaceLightingFactor(highp vec3 modelNormal) { highp vec3 displayNormal = normalize(uNormalTransform * modelNormal); return abs(dot(displayNormal, uLightDirection.xyz)) + uLightDirection.w; @@ -96,32 +92,38 @@ RaycastHit makeRaycastHit(highp vec3 surfacePoint, highp vec3 modelNormal) { } `; -// A box wholly outside the depth range can only produce hits that -// `glsl_raycastFragmentSetup` discards, so culling it costs no coverage. +// The emitters below over-cover so that a primitive straddling the near plane is +// never lost. That also drags an out-of-range primitive back on screen, past a +// fixed-function clipper that can no longer see where it really is. const glsl_raycastDepthRangeCull = ` highp vec2 raycastDepthPlaneDistances(highp vec4 clip) { return vec2(clip.z + clip.w, clip.w - clip.z); } -// Both distances are linear, so callers pass the maximum over the box corners: the -// larger base value plus the magnitude of each half-extent term. Negative form, so -// a non-finite value leaves the box unculled rather than dropping it. +// Both distances are linear, so callers pass the maximum over the box corners. That +// is the larger base value plus the magnitude of each half-extent term. Negative +// form, so a non-finite value fails open and leaves the box drawn. bool raycastBoxOutsideDepthRange(highp vec2 maxDepthDistances) { return maxDepthDistances.x < 0.0 || maxDepthDistances.y < 0.0; } `; +const glsl_raycastQuadConstants = ` +// Must exceed 1.0. Pinned exactly at the viewport edge, the margin an emitter adds +// would drag a fully off-screen primitive back on screen as a sliver. +const highp float RAYCAST_OFFSCREEN_NDC = 2.0; +// Smallest clip w a projected point may be treated as having, as a fraction of the +// local w scale. Relative, so it holds whatever units the projection works in. +const highp float RAYCAST_MIN_RELATIVE_W = 1e-4; +`; + // Emits the screen-axis-aligned quad covering the model-space box // `center +/- halfExtent`. // -// A corner on or behind the near plane must not be dropped -- that would -// under-cover a primitive straddling the near plane and leave it undrawn -- so its -// w is floored positive, which projects it far off-screen, and its NDC is then -// clamped to keep the box finite. Once a corner is clamped the projected-corner -// hull no longer bounds the silhouette, which is what the relative margin covers. +// Dropping a corner on or behind the near plane would leave a primitive that +// straddles the near plane undrawn. Its w is floored positive instead, which throws +// it far off-screen, and the NDC is clamped to keep the box finite. A clamped corner +// no longer bounds the silhouette, which is what the relative margin covers. const glsl_raycastAabbQuad = ` -// Must exceed 1.0 as pinned exactly at the viewport edge, the margin added -// later would drag a fully off-screen primitive back on screen as a sliver. -const highp float RAYCAST_OFFSCREEN_NDC = 2.0; void emitRaycastAabbQuad(highp vec3 center, highp vec3 halfExtent) { highp vec4 clipCenter = uProjection * vec4(center, 1.0); highp vec4 clipX = uProjection[0] * halfExtent.x; @@ -137,6 +139,16 @@ void emitRaycastAabbQuad(highp vec3 center, highp vec3 halfExtent) { return; } + // The largest |w| any corner can reach, and so the box's own w scale. Zero only + // for a zero-extent box on the eye plane, which draws nothing either way. + highp float maxAbsW = abs(clipCenter.w) + + abs(clipX.w) + abs(clipY.w) + abs(clipZ.w); + if (!(maxAbsW > 0.0)) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + highp float minClipW = RAYCAST_MIN_RELATIVE_W * maxAbsW; + highp vec2 ndcMin = vec2(RAYCAST_OFFSCREEN_NDC); highp vec2 ndcMax = vec2(-RAYCAST_OFFSCREEN_NDC); highp float ndcNearZ = 1.0; @@ -146,7 +158,7 @@ void emitRaycastAabbQuad(highp vec3 center, highp vec3 halfExtent) { + ((corner & 1) == 0 ? -clipX : clipX) + ((corner & 2) == 0 ? -clipY : clipY) + ((corner & 4) == 0 ? -clipZ : clipZ); - highp float clipW = max(clip.w, 1e-4); + highp float clipW = max(clip.w, minClipW); highp vec2 ndcXY = clamp(clip.xy / clipW, vec2(-RAYCAST_OFFSCREEN_NDC), vec2(RAYCAST_OFFSCREEN_NDC)); ndcMin = min(ndcMin, ndcXY); @@ -160,14 +172,13 @@ void emitRaycastAabbQuad(highp vec3 center, highp vec3 halfExtent) { } `; -// An OBB about the segment endpointA..endpointB with radial half-extents -// radiusVectorA/B, emitted as a quad oriented along the projected axis. +// A quad oriented along the projected axis, covering the OBB about the segment +// endpointA..endpointB with radial half-extents radiusVectorA/B. // -// Depth-clipping the segment first is what makes an oriented quad possible at all. -// It bounds a primitive crossing the eye plane, whose footprint is otherwise -// unbounded, and it leaves every corner in front of the eye, where the -// projected-corner hull is a valid bound and the screen basis below is real. If a -// corner still grazes the eye plane there is no valid basis, so cover the screen. +// Depth-clipping the segment first is what makes an oriented quad possible. A +// primitive crossing the eye plane has an unbounded footprint, and clipping leaves +// every corner in front of the eye where the projected-corner hull is a valid bound. +// A corner still grazing the eye plane has no valid basis, so cover the screen. const glsl_raycastAxialObbQuad = ` highp vec2 raycastClipToPixels(highp vec4 clip) { return clip.xy / clip.w * uViewportSize * 0.5; @@ -194,7 +205,7 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, min(clipA.w, clipB.w) - abs(clipVectorA.w) - abs(clipVectorB.w); // Positive form, so a non-finite result falls back rather than proceeding. - if (!(clipped && minW > 1e-4 * max(clipA.w, clipB.w))) { + if (!(clipped && minW > RAYCAST_MIN_RELATIVE_W * max(clipA.w, clipB.w))) { gl_Position = vec4(quadCoefficient, 0.0, 1.0); return; } @@ -221,7 +232,7 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, ndcNearZ = min(ndcNearZ, clamp(clip.z / clip.w, -1.0, 1.0)); } - // One pixel for numerical error; the corner bound is otherwise exact. + // The corner bound is exact. One pixel covers numerical error. highp vec2 pixels = pixelCenter + alongDirection * (quadCoefficient.x * (halfAlongPixels + 1.0)) + perpDirection * (quadCoefficient.y * (halfPerpPixels + 1.0)); @@ -230,12 +241,14 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, `; // Model-space radius projecting to `radiusInPixels` device px at `modelPoint`, -// measured on the vertical viewport extent, so raycasts hold a constant on-screen -// size like the billboards they replace. +// measured on the vertical viewport extent. A primitive sized this way holds a +// constant on-screen size as the camera moves. const glsl_raycastPrimitivePixelRadius = ` highp float getRaycastModelRadiusForPixels(highp vec3 modelPoint, highp float radiusInPixels) { - highp float clipW = max((uProjection * vec4(modelPoint, 1.0)).w, 1e-6); - // uInvProjection column 1 is one NDC unit of y in model space; the positive + highp float clipW = (uProjection * vec4(modelPoint, 1.0)).w; + // At or behind the eye there is no on-screen size to match. + if (!(clipW > 0.0)) return 0.0; + // uInvProjection column 1 is one NDC unit of y in model space. The positive // scalar factors straight out of the length. return length(uInvProjection[1].xyz) * (2.0 / uViewportSize.y) * clipW * radiusInPixels; } @@ -244,8 +257,8 @@ highp float getRaycastModelRadiusForPixels(highp vec3 modelPoint, highp float ra export const glsl_raycastFragmentSetup = ` RaycastHit raycastHit = intersectRaycastPrimitive(); if (!raycastHit.hit) discard; -// Positive-form range test, so a non-finite depth is rejected rather than poisoning -// the OIT weight. +// Positive form, so a non-finite depth fails closed rather than poisoning the OIT +// weight. if (!(raycastHit.windowDepth >= 0.0 && raycastHit.windowDepth <= 1.0)) discard; gl_FragDepth = raycastHit.windowDepth; raycastSurfaceDepth = raycastHit.windowDepth; @@ -261,12 +274,13 @@ export function defineRaycastPrimitiveCommon(builder: ShaderBuilder) { builder.addVertexCode(glsl_getQuadVertexPosition); builder.addVertexCode(glsl_clipLineToDepthRange); builder.addVertexCode(glsl_raycastDepthRangeCull); + builder.addVertexCode(glsl_raycastQuadConstants); builder.addVertexCode(glsl_raycastAabbQuad); builder.addVertexCode(glsl_raycastAxialObbQuad); builder.addVertexCode(glsl_raycastPrimitivePixelRadius); builder.addFragmentCode(glsl_raycastPrimitiveFragmentUtil); + builder.addFragmentCode(glsl_splitAlongDirection); builder.addFragmentCode(glsl_intersectRaycastCircle); - builder.addFragmentCode(glsl_splitAboutAxis); } const tempInvProjection = mat4.create(); diff --git a/src/webgl/raycast_shader_lib.ts b/src/webgl/raycast_shader_lib.ts new file mode 100644 index 0000000000..eb637e0ac0 --- /dev/null +++ b/src/webgl/raycast_shader_lib.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file Small GLSL geometry helpers for ray casting. + * + * `intersectRaycastCircle` is adapted from Inigo Quilez's sphere intersector + * (https://iquilezles.org/articles/intersectors/), MIT licensed: + * + * The MIT License. Copyright (c) 2016 Inigo Quilez. + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: the above copyright + * notice and this permission notice shall be included in all copies or + * substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS". + * + * The hit test subtracts the perpendicular distance from the radius rather than + * forming the original `c = dot(oc, oc) - r * r`. Neuroglancer models can sit far + * from the origin, and the rearranged form never subtracts two large numbers. + */ + +export const glsl_splitAlongDirection = ` +struct VectorSplit { + highp float parallelDist; + highp vec3 perpendicular; +}; + +VectorSplit splitAlongDirection(highp vec3 vectorToSplit, highp vec3 unitDirection) { + VectorSplit split; + split.parallelDist = dot(unitDirection, vectorToSplit); + split.perpendicular = vectorToSplit - split.parallelDist * unitDirection; + return split; +} +`; + +export const glsl_intersectRaycastCircle = ` +struct RaycastCircleHit { + bool hit; + // To the near crossing. + highp float distAlongRay; + // Hit point minus centre, not normalised. + highp vec3 normal; +}; + +RaycastCircleHit raycastCircleMiss() { + RaycastCircleHit hit; + hit.hit = false; + return hit; +} + +// Also a sphere test, when the vectors are not confined to one plane. +RaycastCircleHit intersectRaycastCircle(highp vec3 centerToOrigin, + highp vec3 unitDirection, + highp float radius) { + VectorSplit originSplit = splitAlongDirection(centerToOrigin, unitDirection); + highp float radiusSq = radius * radius; + highp float perpendicularDistSq = + dot(originSplit.perpendicular, originSplit.perpendicular); + + // Positive form so that a NaN falls through to the miss. IEEE floats guarantee + // that, GLSL ES does not, so this is defence and not a promise. + if (!(perpendicularDistSq <= radiusSq)) return raycastCircleMiss(); + + highp float halfChord = sqrt(radiusSq - perpendicularDistSq); + // Taking the far crossing instead would fill the view when the camera clips + // inside the geometry. + highp float distAlongRay = -originSplit.parallelDist - halfChord; + if (!(distAlongRay >= 0.0)) return raycastCircleMiss(); + + RaycastCircleHit hit; + hit.hit = true; + hit.distAlongRay = distAlongRay; + hit.normal = originSplit.perpendicular - halfChord * unitDirection; + return hit; +} +`; diff --git a/src/webgl/raycast_sphere.ts b/src/webgl/raycast_sphere.ts index f81bb6e81e..c4cb12e9a5 100644 --- a/src/webgl/raycast_sphere.ts +++ b/src/webgl/raycast_sphere.ts @@ -15,40 +15,13 @@ */ /** - * @file Raycast sphere drawn on a camera-facing quad; see `raycast_primitive.ts` - * for the shared conventions. - * - * Adapted from Inigo Quilez's sphere intersector - * (https://iquilezles.org/articles/intersectors/) and related shadertoy code. - * - * The MIT License. Copyright (c) 2016 Inigo Quilez. - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to - * deal in the Software without restriction, including without limitation the - * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or - * sell copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: the above copyright - * notice and this permission notice shall be included in all copies or - * substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS". - * - * Modifications: - * - The bounding-quad vertex stage has no counterpart in the original, which - * ray-marches a full-screen quad. - * - The discriminant uses the perpendicular-distance rearrangement instead of - * `c = dot(oc, oc) - r * r`, and moves into the shared - * `intersectRaycastCircle` in `raycast_intersect.ts`, which `raycast_cylinder.ts` - * also calls. This is for better scaling as neuroglancer can have large depth - * range. - * - Returns depth and a lighting factor rather than a ray distance. + * @file Raycast sphere drawn on a camera-facing quad. The vertex stage bounds the + * sphere with a quad and the fragment stage returns depth and a lighting factor. */ import { defineRaycastPrimitiveCommon } from "#src/webgl/raycast_primitive.js"; import type { ShaderBuilder } from "#src/webgl/shader.js"; -/** - * Adds `emitRaycastSphere(center, radius)` (vertex) and - * `intersectRaycastPrimitive()` (fragment); `center`/`radius` are in model space. - */ export function defineRaycastSphereShader(builder: ShaderBuilder) { defineRaycastPrimitiveCommon(builder); builder.addVarying("highp vec3", "vSphereCenter", "flat"); @@ -62,16 +35,12 @@ void emitRaycastSphere(highp vec3 center, highp float radius) { `); builder.addFragmentCode(` RaycastHit intersectRaycastPrimitive() { - RaycastRay ray = getRaycastEyeRay(); - - // A sphere is one ray/circle test about its centre C, and nothing else. The - // normal at the hit point H is the standard sphere normal H - C, which - // intersectRaycastCircle returns as offsetFromCenter. + RaycastRay ray = getModelRayThroughFragment(); RaycastCircleHit circleHit = intersectRaycastCircle( ray.origin - vSphereCenter, ray.direction, vSphereRadius); if (!circleHit.hit) return raycastMiss(); - return makeRaycastHit(ray.origin + circleHit.distanceAlongRay * ray.direction, - circleHit.offsetFromCenter); + return makeRaycastHit(ray.origin + circleHit.distAlongRay * ray.direction, + circleHit.normal); } `); } From ac1cb2735835b664ee60d942af7326c7d6c3b577 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 28 Aug 2026 19:03:43 +0200 Subject: [PATCH 18/33] fix: correct anisotropic skeleton source viewing --- src/skeleton/frontend.ts | 93 ++++++++++------ src/webgl/raycast_cylinder.ts | 2 +- src/webgl/raycast_primitive.browser_test.ts | 47 +++++++- src/webgl/raycast_primitive.ts | 117 +++++++++++++------- src/webgl/raycast_sphere.ts | 2 +- 5 files changed, 183 insertions(+), 78 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 076df8f364..882fd28ae5 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -50,7 +50,7 @@ import { } from "#src/trackable_value.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; -import { mat3, mat3FromMat4, mat4, scaleMat3Output } from "#src/util/geom.js"; +import { mat4, vec3 } from "#src/util/geom.js"; import { verifyFinitePositiveFloat } from "#src/util/json.js"; import { NullarySignal } from "#src/util/signal.js"; import type { Trackable } from "#src/util/trackable.js"; @@ -107,7 +107,11 @@ import { import { defineVertexId, VertexIdHelper } from "#src/webgl/vertex_id.js"; const tempModelClip = mat4.create(); -const tempMat3 = mat3.create(); +const tempDisplayClip = mat4.create(); +const tempModelToDisplay = mat4.create(); +const tempCanonicalVoxelScaleMatrix = mat4.create(); +const tempCanonicalVoxelScale = vec3.create(); +const tempInverseCanonicalVoxelScale = vec3.create(); const DEFAULT_FRAGMENT_MAIN = `void main() { emitDefault(); @@ -253,13 +257,16 @@ highp vec3 vertexB = readAttribute0(aVertexIndex.y); if (useRaycast) { defineRaycastCylinderShader(builder); builder.addUniform("highp float", "uEdgePixelRadius"); + builder.addUniform("highp mat4", "uModelToDisplay"); vertexMain += ` highp uint vertexIndex = aVertexIndex.x; -highp float edgeRadius = - getRaycastModelRadiusForPixels(mix(vertexA, vertexB, 0.5), uEdgePixelRadius); -emitRaycastCylinder(vertexA, vertexB, edgeRadius, - getRaycastModelRadiusForPixels(vertexA, uNodeClipPixelRadius), - getRaycastModelRadiusForPixels(vertexB, uNodeClipPixelRadius)); +highp vec3 displayVertexA = (uModelToDisplay * vec4(vertexA, 1.0)).xyz; +highp vec3 displayVertexB = (uModelToDisplay * vec4(vertexB, 1.0)).xyz; +highp float edgeRadius = getRaycastSegmentRadiusForPixels( + displayVertexA, displayVertexB, uEdgePixelRadius); +emitRaycastCylinder(displayVertexA, displayVertexB, edgeRadius, + getRaycastRadiusForPixels(displayVertexA, uNodeClipPixelRadius), + getRaycastRadiusForPixels(displayVertexB, uNodeClipPixelRadius)); `; builder.addFragmentCode(` void emitRGB(vec3 color) { @@ -308,9 +315,12 @@ highp vec3 vertexPosition = readAttribute0(vertexIndex); if (useRaycast) { defineRaycastSphereShader(builder); builder.addUniform("highp float", "uNodePixelRadius"); - vertexMain += `emitRaycastSphere( - vertexPosition, - getRaycastModelRadiusForPixels(vertexPosition, uNodePixelRadius)); + builder.addUniform("highp mat4", "uModelToDisplay"); + vertexMain += ` +highp vec3 displayPosition = (uModelToDisplay * vec4(vertexPosition, 1.0)).xyz; +emitRaycastSphere( + displayPosition, + getRaycastRadiusForPixels(displayPosition, uNodePixelRadius)); `; builder.addFragmentCode(` void emitRGBA(vec4 color) { @@ -419,44 +429,59 @@ void emitDefault() { renderContext: SliceViewPanelRenderContext | PerspectiveViewRenderContext, modelMatrix: mat4, ) { - const { projectionParameters } = renderContext; - const modelClip = mat4.multiply( - tempModelClip, - projectionParameters.viewProjectionMat, - modelMatrix, - ); if (this.raycastEnabled.value) { - this.setRaycastUniforms( - gl, - shader, - renderContext, - modelMatrix, - modelClip, - ); + this.setRaycastUniforms(gl, shader, renderContext, modelMatrix); } else { - gl.uniformMatrix4fv(shader.uniform("uProjection"), false, modelClip); + gl.uniformMatrix4fv( + shader.uniform("uProjection"), + false, + mat4.multiply( + tempModelClip, + renderContext.projectionParameters.viewProjectionMat, + modelMatrix, + ), + ); } this.vertexIdHelper.enable(); } + // The raycast solves a true sphere in display space, which is the global space + // scaled to canonical voxels. Display space reaches the eye through a rotation + // and a uniform scale, so a node is round on screen only when it is round there. + // In layer space an anisotropic dataset would draw every node as an ellipsoid. + // The light direction is given in display space, so the surface normal that the + // raycast returns needs no further transform. private setRaycastUniforms( gl: GL, shader: ShaderProgram, renderContext: SliceViewPanelRenderContext | PerspectiveViewRenderContext, modelMatrix: mat4, - modelClip: mat4, ) { const { projectionParameters } = renderContext; - initializeRaycastPrimitiveShader(shader, modelClip, projectionParameters); - mat3FromMat4(tempMat3, modelMatrix); - scaleMat3Output( - tempMat3, - tempMat3, - projectionParameters.displayDimensionRenderInfo.canonicalVoxelFactors, + const { canonicalVoxelFactors } = + projectionParameters.displayDimensionRenderInfo; + const canonicalVoxelScale = vec3.set( + tempCanonicalVoxelScale, + canonicalVoxelFactors[0], + canonicalVoxelFactors[1], + canonicalVoxelFactors[2], + ); + const modelToDisplay = mat4.multiply( + tempModelToDisplay, + mat4.fromScaling(tempCanonicalVoxelScaleMatrix, canonicalVoxelScale), + modelMatrix, + ); + const displayClip = mat4.scale( + tempDisplayClip, + projectionParameters.viewProjectionMat, + vec3.inverse(tempInverseCanonicalVoxelScale, canonicalVoxelScale), + ); + gl.uniformMatrix4fv( + shader.uniform("uModelToDisplay"), + false, + modelToDisplay, ); - mat3.invert(tempMat3, tempMat3); - mat3.transpose(tempMat3, tempMat3); - gl.uniformMatrix3fv(shader.uniform("uNormalTransform"), false, tempMat3); + initializeRaycastPrimitiveShader(shader, displayClip, projectionParameters); const { lightDirection, ambientLighting, directionalLighting } = renderContext as PerspectiveViewRenderContext; gl.uniform4f( diff --git a/src/webgl/raycast_cylinder.ts b/src/webgl/raycast_cylinder.ts index 2fdfb029a7..226d410c8b 100644 --- a/src/webgl/raycast_cylinder.ts +++ b/src/webgl/raycast_cylinder.ts @@ -65,7 +65,7 @@ bool cylinderPointClipped(highp vec3 surfacePoint) { } RaycastHit intersectRaycastPrimitive() { - RaycastRay ray = getModelRayThroughFragment(); + RaycastRay ray = getRaycastRayThroughFragment(); highp vec3 axisDirection = vCylinderAxis.xyz; highp float axisLength = vCylinderAxis.w; diff --git a/src/webgl/raycast_primitive.browser_test.ts b/src/webgl/raycast_primitive.browser_test.ts index a18ea7a7c6..28c07dd8e7 100644 --- a/src/webgl/raycast_primitive.browser_test.ts +++ b/src/webgl/raycast_primitive.browser_test.ts @@ -49,8 +49,8 @@ void emitShaded() { }); } -// A camera at the model-space origin looking down -z, so a model z of -1 is one -// unit in front of the camera. +// A camera at the raycast-space origin looking down -z, so a raycast-space z of +// -1 is one unit in front of the camera. const COVERAGE_VIEWPORT_SIZE = 64; const COVERAGE_NEAR_BOUND = 0.1; const COVERAGE_FAR_BOUND = 20; @@ -112,7 +112,7 @@ function measureQuadCoverage( } } -// `depth` is the model-space z, negative for in front of the camera. +// `depth` is the raycast-space z, negative for in front of the camera. function cylinderCoverage(gl: GL, depth: number) { return measureQuadCoverage( gl, @@ -134,7 +134,7 @@ describe("raycast primitives", () => { it("compiles the sphere shader", () => { buildShader( defineRaycastSphereShader, - `emitRaycastSphere(vec3(0.0), getRaycastModelRadiusForPixels(vec3(0.0), 5.0));`, + `emitRaycastSphere(vec3(0.0), getRaycastRadiusForPixels(vec3(0.0), 5.0));`, ); }); @@ -142,7 +142,7 @@ describe("raycast primitives", () => { buildShader( defineRaycastCylinderShader, `emitRaycastCylinder(vec3(0.0), vec3(0.0, 1.0, 0.0), - getRaycastModelRadiusForPixels(vec3(0.0), 2.0), 1.0, 1.0);`, + getRaycastRadiusForPixels(vec3(0.0), 2.0), 1.0, 1.0);`, ); }); @@ -155,6 +155,43 @@ describe("raycast primitives", () => { }); }); + // The camera sits inside this tube, whose surface then has no bounded screen + // footprint. Covering the viewport instead would shade every pixel of a + // depth-writing fragment shader, once for each such edge. + it("culls a cylinder that wraps the camera", () => { + webglTest((gl) => { + const coverage = measureQuadCoverage( + gl, + defineRaycastCylinderShader, + `emitRaycastCylinder(vec3(-1.0, 0.0, -0.2), vec3(1.0, 0.0, -0.2), + 0.5, 0.0, 0.0);`, + ); + expect(coverage).toBe(0); + }); + }); + + // This edge crosses the eye plane, so its midpoint lies behind the camera. A + // radius read there is zero and the near half of the edge is lost with it. + it("keeps an edge whose midpoint has passed behind the camera", () => { + webglTest((gl) => { + const endpoints = "vec3(-0.3, -0.2, -1.0), vec3(0.5, 0.4, 1.0)"; + const coverage = (radius: string) => + measureQuadCoverage( + gl, + defineRaycastCylinderShader, + `emitRaycastCylinder(${endpoints}, ${radius}, 0.0, 0.0);`, + ); + expect( + coverage("getRaycastRadiusForPixels(vec3(0.1, 0.1, 0.0), 1.0)"), + ).toBe(0); + const atNearEndpoint = coverage( + `getRaycastSegmentRadiusForPixels(${endpoints}, 1.0)`, + ); + expect(atNearEndpoint).toBeGreaterThan(0.25); + expect(atNearEndpoint).toBeLessThan(1); + }); + }); + it("bounds a sphere tightly, and culls one behind the camera", () => { webglTest((gl) => { const visible = sphereCoverage(gl, -1); diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index f8183f6b3b..d3402ad288 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -18,11 +18,16 @@ * @file Shared GLSL for raycast primitives: a camera facing screen space quad * whose fragment shader ray casts to find the 3D surface. * Emits the depth and a lighting factor. - * Intersection is in model space and must be transformed after finding the hit - * similar to `src/annotation/ellipsoid.ts`. + * + * Positions, radii and normals are in raycast space, the space that `uProjection` + * maps to clip space. That space must reach clip space through a rotation, a + * uniform scale and the projection alone. Any anisotropic scale left in + * `uProjection` draws a sphere as an ellipsoid, because the intersection solves a + * true sphere in raycast space. `uLightDirection` is read in the same space, so + * the surface normal needs no further transform. * * `emitRaycastAabbQuad` and `emitRaycastAxialObbQuad` bound a primitive for - * rasterisation by bounding the object in model space - then projecting to + * rasterisation by bounding the object in raycast space - then projecting to * screen space and emit the screen space quad which covers the * projected bounding box. * Use the AABB (axis aligned bounding box) for objects like spheres, cubes @@ -58,36 +63,34 @@ struct RaycastHit { highp float raycastSurfaceDepth = 0.0; highp float raycastLightingFactor = 1.0; -RaycastRay getModelRayThroughFragment() { +RaycastRay getRaycastRayThroughFragment() { highp vec2 ndc = (gl_FragCoord.xy / uViewportSize) * 2.0 - 1.0; highp vec4 nearClip = uInvProjection * vec4(ndc, -1.0, 1.0); highp vec4 farClip = uInvProjection * vec4(ndc, 1.0, 1.0); - highp vec3 nearModel = nearClip.xyz / nearClip.w; - highp vec3 farModel = farClip.xyz / farClip.w; + highp vec3 nearPoint = nearClip.xyz / nearClip.w; + highp vec3 farPoint = farClip.xyz / farClip.w; RaycastRay ray; - ray.origin = nearModel; - ray.direction = normalize(farModel - nearModel); + ray.origin = nearPoint; + ray.direction = normalize(farPoint - nearPoint); return ray; } -highp float getRaycastWindowDepth(highp vec3 modelPoint) { - highp vec4 clip = uProjection * vec4(modelPoint, 1.0); +highp float getRaycastWindowDepth(highp vec3 point) { + highp vec4 clip = uProjection * vec4(point, 1.0); return 0.5 * (clip.z / clip.w) + 0.5; } -// modelNormal need not be normalised. -highp float getRaycastSurfaceLightingFactor(highp vec3 modelNormal) { - highp vec3 displayNormal = normalize(uNormalTransform * modelNormal); - return abs(dot(displayNormal, uLightDirection.xyz)) + uLightDirection.w; +highp float getRaycastSurfaceLightingFactor(highp vec3 normal) { + return abs(dot(normalize(normal), uLightDirection.xyz)) + uLightDirection.w; } RaycastHit raycastMiss() { RaycastHit hit; hit.hit = false; return hit; } -RaycastHit makeRaycastHit(highp vec3 surfacePoint, highp vec3 modelNormal) { +RaycastHit makeRaycastHit(highp vec3 surfacePoint, highp vec3 normal) { RaycastHit hit; hit.hit = true; hit.windowDepth = getRaycastWindowDepth(surfacePoint); - hit.lightingFactor = getRaycastSurfaceLightingFactor(modelNormal); + hit.lightingFactor = getRaycastSurfaceLightingFactor(normal); return hit; } `; @@ -114,9 +117,13 @@ const highp float RAYCAST_OFFSCREEN_NDC = 2.0; // Smallest clip w a projected point may be treated as having, as a fraction of the // local w scale. Relative, so it holds whatever units the projection works in. const highp float RAYCAST_MIN_RELATIVE_W = 1e-4; +// Nearest clip w an axis may keep, as a multiple of the depth that the radial +// half-extents span. The margin over 1.0 is what a corner keeps in front of the +// eye, and so what caps how far outside the viewport a corner can project. +const highp float RAYCAST_MIN_AXIS_W_MARGIN = 1.25; `; -// Emits the screen-axis-aligned quad covering the model-space box +// Emits the screen-axis-aligned quad covering the raycast-space box // `center +/- halfExtent`. // // Dropping a corner on or behind the near plane would leave a primitive that @@ -178,7 +185,9 @@ void emitRaycastAabbQuad(highp vec3 center, highp vec3 halfExtent) { // Depth-clipping the segment first is what makes an oriented quad possible. A // primitive crossing the eye plane has an unbounded footprint, and clipping leaves // every corner in front of the eye where the projected-corner hull is a valid bound. -// A corner still grazing the eye plane has no valid basis, so cover the screen. +// The radial half-extents reach nearer than the axis does, so the near end is +// trimmed again by their own depth. The part dropped there wraps the eye, and no +// quad of bounded size covers it. const glsl_raycastAxialObbQuad = ` highp vec2 raycastClipToPixels(highp vec4 clip) { return clip.xy / clip.w * uViewportSize * 0.5; @@ -201,17 +210,31 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, // Clips clipA and clipB in place, so everything below uses the clipped segment. bool clipped = clipLineToDepthRange(clipA, clipB); - highp float minW = - min(clipA.w, clipB.w) - abs(clipVectorA.w) - abs(clipVectorB.w); - // Positive form, so a non-finite result falls back rather than proceeding. - if (!(clipped && minW > RAYCAST_MIN_RELATIVE_W * max(clipA.w, clipB.w))) { - gl_Position = vec4(quadCoefficient, 0.0, 1.0); + // w runs linearly along the axis, so one crossing bounds the near end. + highp float radialW = abs(clipVectorA.w) + abs(clipVectorB.w); + highp float minAxisW = max(radialW * RAYCAST_MIN_AXIS_W_MARGIN, + RAYCAST_MIN_RELATIVE_W * max(clipA.w, clipB.w)); + highp float axisDeltaW = clipB.w - clipA.w; + highp float startT = 0.0; + highp float endT = 1.0; + if (axisDeltaW > 0.0) { + startT = max(startT, (minAxisW - clipA.w) / axisDeltaW); + } else if (axisDeltaW < 0.0) { + endT = min(endT, (minAxisW - clipA.w) / axisDeltaW); + } + highp vec4 axisA = mix(clipA, clipB, startT); + highp vec4 axisB = mix(clipA, clipB, endT); + + // Positive form, so a non-finite result culls rather than proceeding. The equal + // depth case trims nothing, so the corner test still has to reject it. + if (!(clipped && startT < endT && min(axisA.w, axisB.w) >= radialW)) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; } - highp vec2 pixelsA = raycastClipToPixels(clipA); - highp vec2 pixelsB = raycastClipToPixels(clipB); + highp vec2 pixelsA = raycastClipToPixels(axisA); + highp vec2 pixelsB = raycastClipToPixels(axisB); highp vec2 axisPixels = pixelsB - pixelsA; highp float axisLengthPixels = length(axisPixels); highp vec2 alongDirection = @@ -223,7 +246,7 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, highp float ndcNearZ = 1.0; for (int corner = 0; corner < 8; ++corner) { - highp vec4 clip = ((corner & 1) == 0 ? clipA : clipB) + highp vec4 clip = ((corner & 1) == 0 ? axisA : axisB) + ((corner & 2) == 0 ? -clipVectorA : clipVectorA) + ((corner & 4) == 0 ? -clipVectorB : clipVectorB); highp vec2 offset = raycastClipToPixels(clip) - pixelCenter; @@ -240,18 +263,37 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, } `; -// Model-space radius projecting to `radiusInPixels` device px at `modelPoint`, -// measured on the vertical viewport extent. A primitive sized this way holds a -// constant on-screen size as the camera moves. +// Raycast-space radius projecting to `radiusInPixels` device px, measured on the +// vertical viewport extent. A primitive sized this way holds a constant on-screen +// size as the camera moves. const glsl_raycastPrimitivePixelRadius = ` -highp float getRaycastModelRadiusForPixels(highp vec3 modelPoint, highp float radiusInPixels) { - highp float clipW = (uProjection * vec4(modelPoint, 1.0)).w; +highp float raycastRadiusFromClipW(highp float clipW, highp float radiusInPixels) { // At or behind the eye there is no on-screen size to match. if (!(clipW > 0.0)) return 0.0; - // uInvProjection column 1 is one NDC unit of y in model space. The positive - // scalar factors straight out of the length. + // uInvProjection column 1 is one NDC unit of y in raycast space. The positive + // scalar factors straight out of the length. Raycast space reaches the eye + // through a rotation and a uniform scale, so the length does not turn with the + // camera. return length(uInvProjection[1].xyz) * (2.0 / uViewportSize.y) * clipW * radiusInPixels; } +highp float getRaycastRadiusForPixels(highp vec3 point, highp float radiusInPixels) { + return raycastRadiusFromClipW((uProjection * vec4(point, 1.0)).w, radiusInPixels); +} +// One radius for a whole segment, read at the endpoint nearest the eye. +// +// The midpoint drops to zero once it passes behind the eye, which loses a segment +// whose near half is still in view. The nearest endpoint also holds the radius +// below what the same endpoint yields for any wider pixel radius, so a cap sized +// that way still covers the end. +highp float getRaycastSegmentRadiusForPixels( + highp vec3 endpointA, highp vec3 endpointB, highp float radiusInPixels) { + highp float clipWA = (uProjection * vec4(endpointA, 1.0)).w; + highp float clipWB = (uProjection * vec4(endpointB, 1.0)).w; + // Both endpoints behind the eye put the whole segment behind it. + highp float nearClipW = min(clipWA, clipWB); + return raycastRadiusFromClipW( + nearClipW > 0.0 ? nearClipW : max(clipWA, clipWB), radiusInPixels); +} `; export const glsl_raycastFragmentSetup = ` @@ -268,7 +310,6 @@ raycastLightingFactor = raycastHit.lightingFactor; export function defineRaycastPrimitiveCommon(builder: ShaderBuilder) { builder.require(projectionMatrixShaderModule); builder.addUniform("highp mat4", "uInvProjection"); - builder.addUniform("highp mat3", "uNormalTransform"); builder.addUniform("highp vec4", "uLightDirection"); builder.addUniform("highp vec2", "uViewportSize"); builder.addVertexCode(glsl_getQuadVertexPosition); @@ -285,14 +326,16 @@ export function defineRaycastPrimitiveCommon(builder: ShaderBuilder) { const tempInvProjection = mat4.create(); +// `raycastClip` maps raycast space to clip space. See the constraint on that +// space at the top of this file. export function initializeRaycastPrimitiveShader( shader: ShaderProgram, - modelClip: mat4, + raycastClip: mat4, projectionParameters: { width: number; height: number }, ) { const { gl } = shader; - gl.uniformMatrix4fv(shader.uniform("uProjection"), false, modelClip); - mat4.invert(tempInvProjection, modelClip); + gl.uniformMatrix4fv(shader.uniform("uProjection"), false, raycastClip); + mat4.invert(tempInvProjection, raycastClip); gl.uniformMatrix4fv( shader.uniform("uInvProjection"), false, diff --git a/src/webgl/raycast_sphere.ts b/src/webgl/raycast_sphere.ts index c4cb12e9a5..bfb453b8b2 100644 --- a/src/webgl/raycast_sphere.ts +++ b/src/webgl/raycast_sphere.ts @@ -35,7 +35,7 @@ void emitRaycastSphere(highp vec3 center, highp float radius) { `); builder.addFragmentCode(` RaycastHit intersectRaycastPrimitive() { - RaycastRay ray = getModelRayThroughFragment(); + RaycastRay ray = getRaycastRayThroughFragment(); RaycastCircleHit circleHit = intersectRaycastCircle( ray.origin - vSphereCenter, ray.direction, vSphereRadius); if (!circleHit.hit) return raycastMiss(); From 34c55162154448a896de44fc7ad8a6139dd97fec Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 31 Aug 2026 09:50:20 +0200 Subject: [PATCH 19/33] fix: cull 0 radius cylinders and balls --- src/webgl/raycast_cylinder.ts | 6 ++ src/webgl/raycast_primitive.browser_test.ts | 99 ++++++++++++++++++--- src/webgl/raycast_sphere.ts | 6 ++ 3 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/webgl/raycast_cylinder.ts b/src/webgl/raycast_cylinder.ts index 226d410c8b..c476ad4e26 100644 --- a/src/webgl/raycast_cylinder.ts +++ b/src/webgl/raycast_cylinder.ts @@ -38,6 +38,12 @@ export function defineRaycastCylinderShader(builder: ShaderBuilder) { void emitRaycastCylinder(highp vec3 endpointA, highp vec3 endpointB, highp float radius, highp float clipRadiusA, highp float clipRadiusB) { + // No radius, no surface to hit. A segment with both endpoints behind the eye + // reaches this every frame. Positive form, so a non-finite radius culls too. + if (!(radius > 0.0)) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } vCylinderEndpointA = endpointA; vCylinderEndpointB = endpointB; vCylinderRadius = radius; diff --git a/src/webgl/raycast_primitive.browser_test.ts b/src/webgl/raycast_primitive.browser_test.ts index 28c07dd8e7..2989d7439c 100644 --- a/src/webgl/raycast_primitive.browser_test.ts +++ b/src/webgl/raycast_primitive.browser_test.ts @@ -55,22 +55,19 @@ const COVERAGE_VIEWPORT_SIZE = 64; const COVERAGE_NEAR_BOUND = 0.1; const COVERAGE_FAR_BOUND = 20; -// Fraction of the viewport that the bounding quad rasterises. The fragment -// shader writes unconditionally, so this measures the vertex stage: an -// out-of-range quad is counted here but discarded by the real shader, making it -// invisible to any test of the shaded result. -function measureQuadCoverage( +function renderPrimitive( gl: GL, definePrimitive: (builder: ShaderBuilder) => void, emitPrimitive: string, -): number { + fragmentMain: string, +): Uint8Array { const size = COVERAGE_VIEWPORT_SIZE; const builder = new ShaderBuilder(gl); builder.addOutputBuffer("vec4", "out_color", 0); defineVertexId(builder); definePrimitive(builder); builder.setVertexMain(emitPrimitive); - builder.setFragmentMain("out_color = vec4(1.0, 1.0, 1.0, 1.0);\n"); + builder.setFragmentMain(fragmentMain); const shader = builder.build(); const vertexIdHelper = VertexIdHelper.get(gl); try { @@ -101,17 +98,36 @@ function measureQuadCoverage( WebGL2RenderingContext.UNSIGNED_BYTE, pixels, ); - let covered = 0; - for (let i = 0; i < size * size; ++i) { - if (pixels[i * 4] !== 0) ++covered; - } - return covered / (size * size); + return pixels; } finally { vertexIdHelper.disable(); shader.dispose(); } } +// Fraction of the viewport that the bounding quad rasterises. The fragment +// shader writes unconditionally, so this measures the vertex stage: an +// out-of-range quad is counted here but discarded by the real shader, making it +// invisible to any test of the shaded result. +function measureQuadCoverage( + gl: GL, + definePrimitive: (builder: ShaderBuilder) => void, + emitPrimitive: string, +): number { + const pixels = renderPrimitive( + gl, + definePrimitive, + emitPrimitive, + "out_color = vec4(1.0, 1.0, 1.0, 1.0);\n", + ); + const size = COVERAGE_VIEWPORT_SIZE; + let covered = 0; + for (let i = 0; i < size * size; ++i) { + if (pixels[i * 4] !== 0) ++covered; + } + return covered / (size * size); +} + // `depth` is the raycast-space z, negative for in front of the camera. function cylinderCoverage(gl: GL, depth: number) { return measureQuadCoverage( @@ -192,6 +208,65 @@ describe("raycast primitives", () => { }); }); + // A skeleton edge carries a vertex attribute at each end, and the consumer mixes + // the two by this fraction. A constant value would colour a whole edge from one + // endpoint, so the test checks that it runs the length of the tube. + it("reports where a cylinder hit falls between the endpoints", () => { + webglTest((gl) => { + const size = COVERAGE_VIEWPORT_SIZE; + const pixels = renderPrimitive( + gl, + defineRaycastCylinderShader, + `emitRaycastCylinder(vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0), + 0.05, 0.0, 0.0);`, + glsl_raycastFragmentSetup + + "out_color = vec4(raycastCylinderAxialFraction, 1.0, 0.0, 1.0);\n", + ); + // Endpoint A is the lower end, and readPixels returns rows bottom up. + const fractionByRow: number[] = []; + for (let row = 0; row < size; ++row) { + for (let column = 0; column < size; ++column) { + const offset = (row * size + column) * 4; + if (pixels[offset + 1] !== 0) { + fractionByRow.push(pixels[offset]); + break; + } + } + } + expect(fractionByRow.length).toBeGreaterThan(8); + const [first, last] = [fractionByRow[0], fractionByRow.at(-1)!]; + expect(first).toBeLessThan(16); + expect(last).toBeGreaterThan(239); + for (let i = 1; i < fractionByRow.length; ++i) { + expect(fractionByRow[i]).toBeGreaterThanOrEqual(fractionByRow[i - 1]); + } + }); + }); + + // A radius of zero has no surface for the fragment shader to hit, and reaching + // the quad emitters with one leaves the radius vectors degenerate. Both radius + // helpers return zero for a point at or behind the eye, so this runs every frame + // on any skeleton with geometry behind the camera. + it("culls a zero-radius primitive", () => { + webglTest((gl) => { + expect( + measureQuadCoverage( + gl, + defineRaycastCylinderShader, + `emitRaycastCylinder(vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0), + 0.0, 0.0, 0.0);`, + ), + ).toBe(0); + expect( + measureQuadCoverage( + gl, + defineRaycastSphereShader, + "emitRaycastSphere(vec3(0.0, 0.0, -1.0), 0.0);", + ), + ).toBe(0); + }); + }); + it("bounds a sphere tightly, and culls one behind the camera", () => { webglTest((gl) => { const visible = sphereCoverage(gl, -1); diff --git a/src/webgl/raycast_sphere.ts b/src/webgl/raycast_sphere.ts index bfb453b8b2..30ef65fc28 100644 --- a/src/webgl/raycast_sphere.ts +++ b/src/webgl/raycast_sphere.ts @@ -28,6 +28,12 @@ export function defineRaycastSphereShader(builder: ShaderBuilder) { builder.addVarying("highp float", "vSphereRadius", "flat"); builder.addVertexCode(` void emitRaycastSphere(highp vec3 center, highp float radius) { + // No radius, no surface to hit. A node behind the eye reaches this every frame. + // Positive form, so a non-finite radius culls too. + if (!(radius > 0.0)) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } vSphereCenter = center; vSphereRadius = radius; emitRaycastAabbQuad(center, vec3(radius)); From 6901686ff9aafbc2d279840b9f017d899dc1a4f4 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 31 Aug 2026 09:55:15 +0200 Subject: [PATCH 20/33] fix: more accurate clipping on lines and pack varyings --- src/webgl/lines.ts | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/webgl/lines.ts b/src/webgl/lines.ts index 9a939701a2..cba11e5dc1 100644 --- a/src/webgl/lines.ts +++ b/src/webgl/lines.ts @@ -47,9 +47,10 @@ export function defineLineShader( // max(1e-6, featherWidth) / (lineWidth + featherWidth) builder.addVarying("highp float", "vLineFeatherFraction"); if (endpointClipping) { - builder.addVarying("highp float", "vLineOffsetX"); - builder.addVarying("highp float", "vLineLengthInPixels", "flat"); - builder.addVarying("highp float", "vLineHalfWidthInPixels", "flat"); + // Window coordinates, matching gl_FragCoord.xy, of the endpoints as given. + // Depth clipping moves the drawn ends, so these are taken before it. + // xy: endpoint A, zw: endpoint B. + builder.addVarying("highp vec4", "vLineEndpointsWindow", "flat"); builder.addVarying("highp float", "vLineEndpointClipRadius", "flat"); } if (rounded) { @@ -61,12 +62,29 @@ export function defineLineShader( } builder.addVertexCode(glsl_clipLineToDepthRange); builder.addVertexCode(` +${ + endpointClipping + ? `// Far off screen for a point at or behind the eye, which has no window position +// and so no clip disc to draw. +highp vec2 lineClipToWindow(vec4 clip) { + if (!(clip.w > 0.0)) return vec2(-1e6); + return (clip.xy / clip.w * 0.5 + 0.5) / uLineParams.xy; +}` + : "" +} vec2 getLineOffset() { return getQuadVertexPosition(vec2(0.0, -1.0), vec2(1.0, 1.0)); } float getLineEndpointCoefficient() { return getLineOffset().x; } uint getLineEndpointIndex() { return uint(getLineEndpointCoefficient()); } void emitLine(vec4 vertexAClip, vec4 vertexBClip, float lineWidthInPixels ${rounded ? ", float borderWidth" : ""} ${endpointClipping ? ", float endpointClipRadiusInPixels" : ""}) { + ${ + endpointClipping + ? `vLineEndpointsWindow = vec4(lineClipToWindow(vertexAClip), + lineClipToWindow(vertexBClip)); + vLineEndpointClipRadius = endpointClipRadiusInPixels;` + : "" + } if (!clipLineToDepthRange(vertexAClip, vertexBClip)) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; @@ -101,14 +119,6 @@ void emitLine(vec4 vertexAClip, vec4 vertexBClip, float lineWidthInPixels }) * totalLineWidth * uLineParams.xy; vLineCoord = lineOffset.y; - ${ - endpointClipping - ? `vLineOffsetX = lineOffset.x; - vLineLengthInPixels = linePixelLength; - vLineHalfWidthInPixels = totalLineWidth * 0.5; - vLineEndpointClipRadius = endpointClipRadiusInPixels;` - : "" - } ${ rounded ? "vEndpointFraction = totalLineWidth / (linePixelLength + totalLineWidth * 2.0);" @@ -155,9 +165,8 @@ float getLineAlpha() { ${ endpointClipping ? `if (vLineEndpointClipRadius > 0.0) { - float offsetY = vLineCoord * vLineHalfWidthInPixels; - float distFromA = length(vec2(vLineOffsetX * vLineLengthInPixels, offsetY)); - float distFromB = length(vec2((1.0 - vLineOffsetX) * vLineLengthInPixels, offsetY)); + float distFromA = distance(gl_FragCoord.xy, vLineEndpointsWindow.xy); + float distFromB = distance(gl_FragCoord.xy, vLineEndpointsWindow.zw); if (min(distFromA, distFromB) < vLineEndpointClipRadius) discard; }` : "" From aa982f6c36bdb7d8cec9e773ce3ce1a4a87145df Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 31 Aug 2026 15:09:52 +0200 Subject: [PATCH 21/33] fix: pack varyings for space and interpolate user attrs for cylinders --- python/tests/skeleton_rendering_test.py | 4 +- src/skeleton/frontend.ts | 41 +++++-- src/webgl/lines.browser_test.ts | 127 ++++++++++++++++++++ src/webgl/raycast_cylinder.ts | 57 +++++---- src/webgl/raycast_primitive.browser_test.ts | 76 ++++++++---- src/webgl/raycast_sphere.ts | 9 +- 6 files changed, 255 insertions(+), 59 deletions(-) create mode 100644 src/webgl/lines.browser_test.ts diff --git a/python/tests/skeleton_rendering_test.py b/python/tests/skeleton_rendering_test.py index 7748f6c419..a8a2a9c1a9 100644 --- a/python/tests/skeleton_rendering_test.py +++ b/python/tests/skeleton_rendering_test.py @@ -122,13 +122,13 @@ def test_skeleton_options(webdriver): ("3d", "lines", FLAT), ("3d", "lines_and_points", FLAT), ("3d", "cylinders", LIT), - ("3d", "cylinders_and_spheres", LIT), + ("3d", "cylinders_and_balls", LIT), ] ENLARGED_PAIRS = [ ("xy", "lines", "lines_and_points"), ("3d", "lines", "lines_and_points"), - ("3d", "cylinders", "cylinders_and_spheres"), + ("3d", "cylinders", "cylinders_and_balls"), ] diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 882fd28ae5..35fbb22e20 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -122,7 +122,7 @@ export enum SkeletonRenderMode3d { LINES = 0, LINES_AND_POINTS = 1, CYLINDERS = 2, - CYLINDERS_AND_SPHERES = 3, + CYLINDERS_AND_BALLS = 3, } export enum SkeletonRenderMode2d { @@ -135,14 +135,14 @@ export type SkeletonRenderMode = SkeletonRenderMode2d | SkeletonRenderMode3d; function isRaycastMode(mode: SkeletonRenderMode) { return ( mode === SkeletonRenderMode3d.CYLINDERS || - mode === SkeletonRenderMode3d.CYLINDERS_AND_SPHERES + mode === SkeletonRenderMode3d.CYLINDERS_AND_BALLS ); } function hasEnlargedNodes(mode: SkeletonRenderMode) { return ( mode === SkeletonRenderMode3d.LINES_AND_POINTS || - mode === SkeletonRenderMode3d.CYLINDERS_AND_SPHERES + mode === SkeletonRenderMode3d.CYLINDERS_AND_BALLS ); } @@ -259,7 +259,6 @@ highp vec3 vertexB = readAttribute0(aVertexIndex.y); builder.addUniform("highp float", "uEdgePixelRadius"); builder.addUniform("highp mat4", "uModelToDisplay"); vertexMain += ` -highp uint vertexIndex = aVertexIndex.x; highp vec3 displayVertexA = (uModelToDisplay * vec4(vertexA, 1.0)).xyz; highp vec3 displayVertexB = (uModelToDisplay * vec4(vertexB, 1.0)).xyz; highp float edgeRadius = getRaycastSegmentRadiusForPixels( @@ -299,6 +298,7 @@ void emitDefault() { shaderBuilderState, vertexMain, useRaycast, + useRaycast ? "raycastCylinderAxialFraction" : undefined, ); } @@ -355,11 +355,16 @@ void emitDefault() { ); } + // `edgeMixExpression` is set only where one draw covers a whole edge, as the + // cylinder does. A vertex attribute has a value at each end, and the expression + // gives where the fragment falls between them. Without it every fragment of an + // edge would read the same end. private finalizeShaderBuilder( builder: ShaderBuilder, shaderBuilderState: ShaderControlsBuilderState, vertexMain: string, useRaycast: boolean, + edgeMixExpression?: string, ) { if (shaderBuilderState.parseResult.errors.length !== 0) { throw new Error("Invalid UI control specification"); @@ -368,10 +373,30 @@ void emitDefault() { const { vertexAttributes } = this; for (let i = 1; i < vertexAttributes.length; ++i) { const info = vertexAttributes[i]; - builder.addVarying(`highp ${info.glslDataType}`, `vCustom${i}`); - vertexMain += `vCustom${i} = readAttribute${i}(vertexIndex);\n`; - builder.addFragmentCode(`#define ${info.name} vCustom${i}\n`); - builder.addFragmentCode(`#define prop_${info.name}() vCustom${i}\n`); + let attributeExpression: string; + if (edgeMixExpression === undefined) { + builder.addVarying(`highp ${info.glslDataType}`, `vCustom${i}`); + vertexMain += `vCustom${i} = readAttribute${i}(vertexIndex);\n`; + attributeExpression = `vCustom${i}`; + } else { + builder.addVarying( + `highp ${info.glslDataType}`, + `vCustomA${i}`, + "flat", + ); + builder.addVarying( + `highp ${info.glslDataType}`, + `vCustomB${i}`, + "flat", + ); + vertexMain += `vCustomA${i} = readAttribute${i}(aVertexIndex.x);\n`; + vertexMain += `vCustomB${i} = readAttribute${i}(aVertexIndex.y);\n`; + attributeExpression = `mix(vCustomA${i}, vCustomB${i}, ${edgeMixExpression})`; + } + builder.addFragmentCode(`#define ${info.name} ${attributeExpression}\n`); + builder.addFragmentCode( + `#define prop_${info.name}() ${attributeExpression}\n`, + ); } builder.setVertexMain(vertexMain); addControlsToBuilder(shaderBuilderState, builder); diff --git a/src/webgl/lines.browser_test.ts b/src/webgl/lines.browser_test.ts new file mode 100644 index 0000000000..e0dd045a87 --- /dev/null +++ b/src/webgl/lines.browser_test.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from "vitest"; +import type { GL } from "#src/webgl/context.js"; +import { + defineLineShader, + drawLines, + initializeLineShader, +} from "#src/webgl/lines.js"; +import { ShaderBuilder } from "#src/webgl/shader.js"; +import { webglTest } from "#src/webgl/testing.js"; +import { defineVertexId, VertexIdHelper } from "#src/webgl/vertex_id.js"; + +const VIEWPORT_SIZE = 64; +const LINE_WIDTH_IN_PIXELS = 6; +const CLIP_RADIUS_IN_PIXELS = 10; + +// One line, drawn with endpoint clipping, read back as the set of covered pixels. +// `endpointsClip` gives both endpoints in clip space, so a test can put an +// endpoint outside the depth range without setting up a projection. +function drawClippedLine( + gl: GL, + endpointsClip: string, + clipRadiusInPixels: number, +): Uint8Array { + const size = VIEWPORT_SIZE; + const builder = new ShaderBuilder(gl); + builder.addOutputBuffer("vec4", "out_color", 0); + defineVertexId(builder); + defineLineShader(builder, /*rounded=*/ false, /*endpointClipping=*/ true); + builder.setVertexMain( + `emitLine(${endpointsClip}, ${LINE_WIDTH_IN_PIXELS.toFixed(1)}, ` + + `${clipRadiusInPixels.toFixed(1)});`, + ); + builder.setFragmentMain("out_color = vec4(getLineAlpha());\n"); + const shader = builder.build(); + const vertexIdHelper = VertexIdHelper.get(gl); + try { + shader.bind(); + vertexIdHelper.enable(); + initializeLineShader( + shader, + { width: size, height: size }, + /*featherWidthInPixels=*/ 0, + ); + gl.viewport(0, 0, size, size); + gl.clearColor(0, 0, 0, 0); + gl.clear(WebGL2RenderingContext.COLOR_BUFFER_BIT); + drawLines(gl, 1, 1); + const pixels = new Uint8Array(size * size * 4); + gl.readPixels( + 0, + 0, + size, + size, + WebGL2RenderingContext.RGBA, + WebGL2RenderingContext.UNSIGNED_BYTE, + pixels, + ); + const covered = new Uint8Array(size * size); + for (let i = 0; i < size * size; ++i) { + covered[i] = pixels[i * 4] !== 0 ? 1 : 0; + } + return covered; + } finally { + vertexIdHelper.disable(); + shader.dispose(); + } +} + +function countCovered(covered: Uint8Array): number { + let total = 0; + for (const value of covered) total += value; + return total; +} + +function isCovered(covered: Uint8Array, x: number, y: number): boolean { + return covered[y * VIEWPORT_SIZE + x] === 1; +} + +describe("line endpoint clipping", () => { + // A clip disc belongs at each endpoint, so that a node drawn there has room. + it("removes a disc at each endpoint", () => { + webglTest((gl) => { + const endpoints = "vec4(-0.5, 0.0, 0.0, 1.0), vec4(0.5, 0.0, 0.0, 1.0)"; + const unclipped = drawClippedLine(gl, endpoints, 0); + const clipped = drawClippedLine(gl, endpoints, CLIP_RADIUS_IN_PIXELS); + expect(countCovered(clipped)).toBeGreaterThan(0); + expect(countCovered(clipped)).toBeLessThan(countCovered(unclipped)); + // Endpoint A sits at NDC x of -0.5, which is a quarter across the viewport. + const endpointAX = VIEWPORT_SIZE / 4; + const centerY = VIEWPORT_SIZE / 2; + expect(isCovered(unclipped, endpointAX, centerY)).toBe(true); + expect(isCovered(clipped, endpointAX, centerY)).toBe(false); + }); + }); + + // Depth clipping moves the drawn ends inward. Measuring the clip disc from + // those moved ends would eat the drawn line at a point where no node exists, + // because the node itself was clipped away with the rest of the segment. + it("measures from the given endpoints, not the depth-clipped ones", () => { + webglTest((gl) => { + // z runs from -3 to 3, so only the middle third survives the depth range. + // Both given endpoints end up more than one clip radius clear of what is + // drawn, so the discs must remove nothing. + const endpoints = "vec4(-1.0, 0.0, -3.0, 1.0), vec4(1.0, 0.0, 3.0, 1.0)"; + const unclipped = drawClippedLine(gl, endpoints, 0); + const clipped = drawClippedLine(gl, endpoints, CLIP_RADIUS_IN_PIXELS); + expect(countCovered(unclipped)).toBeGreaterThan(0); + expect(countCovered(clipped)).toBe(countCovered(unclipped)); + }); + }); +}); diff --git a/src/webgl/raycast_cylinder.ts b/src/webgl/raycast_cylinder.ts index c476ad4e26..012c872386 100644 --- a/src/webgl/raycast_cylinder.ts +++ b/src/webgl/raycast_cylinder.ts @@ -27,13 +27,13 @@ import type { ShaderBuilder } from "#src/webgl/shader.js"; export function defineRaycastCylinderShader(builder: ShaderBuilder) { defineRaycastPrimitiveCommon(builder); - builder.addVarying("highp vec3", "vCylinderEndpointA", "flat"); - builder.addVarying("highp vec3", "vCylinderEndpointB", "flat"); + // The cylinder is a base circle swept along an axis. xyz: center of that + // circle, which is endpoint A. w: its radius. + builder.addVarying("highp vec4", "vCylinderBaseCircle", "flat"); // xyz: unit axis direction, w: axis length. builder.addVarying("highp vec4", "vCylinderAxis", "flat"); - builder.addVarying("highp float", "vCylinderRadius", "flat"); - builder.addVarying("highp float", "vCylinderClipRadiusA", "flat"); - builder.addVarying("highp float", "vCylinderClipRadiusB", "flat"); + // x: clip radius at endpoint A, y: clip radius at endpoint B. + builder.addVarying("highp vec2", "vCylinderClipRadii", "flat"); builder.addVertexCode(` void emitRaycastCylinder(highp vec3 endpointA, highp vec3 endpointB, highp float radius, @@ -44,39 +44,50 @@ void emitRaycastCylinder(highp vec3 endpointA, highp vec3 endpointB, gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; } - vCylinderEndpointA = endpointA; - vCylinderEndpointB = endpointB; - vCylinderRadius = radius; - vCylinderClipRadiusA = clipRadiusA; - vCylinderClipRadiusB = clipRadiusB; + vCylinderBaseCircle = vec4(endpointA, radius); + vCylinderClipRadii = vec2(clipRadiusA, clipRadiusB); highp vec3 axisVector = endpointB - endpointA; highp float axisLength = length(axisVector); highp vec3 axisDirection = axisLength > 1e-6 ? axisVector / axisLength : vec3(0.0, 1.0, 0.0); vCylinderAxis = vec4(axisDirection, axisLength); - // Two perpendicular radius vectors spanning the circular cross-section. + // Two perpendicular radius vectors spanning the circular cross-section. The + // scale by radius comes last: a zero radius would otherwise leave the second + // cross product normalising the zero vector, which GLSL ES leaves undefined. highp vec3 offAxisVector = abs(axisDirection.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0); - highp vec3 radiusVectorA = normalize(cross(offAxisVector, axisDirection)) * radius; - highp vec3 radiusVectorB = normalize(cross(axisDirection, radiusVectorA)) * radius; - emitRaycastAxialObbQuad(endpointA, endpointB, radiusVectorA, radiusVectorB); + highp vec3 unitRadiusA = normalize(cross(offAxisVector, axisDirection)); + // Already unit length, being the cross product of two perpendicular unit vectors. + highp vec3 unitRadiusB = cross(axisDirection, unitRadiusA); + emitRaycastAxialObbQuad(endpointA, endpointB, + unitRadiusA * radius, unitRadiusB * radius); } `); builder.addFragmentCode(` -bool cylinderPointClipped(highp vec3 surfacePoint) { - highp vec3 offsetA = surfacePoint - vCylinderEndpointA; - highp vec3 offsetB = surfacePoint - vCylinderEndpointB; - return dot(offsetA, offsetA) < vCylinderClipRadiusA * vCylinderClipRadiusA || - dot(offsetB, offsetB) < vCylinderClipRadiusB * vCylinderClipRadiusB; +// Where the surface point falls between the endpoints, 0.0 at A and 1.0 at B. +// Only meaningful once intersectRaycastPrimitive has returned a hit. +highp float raycastCylinderAxialFraction = 0.0; + +// A surface point sits exactly one radius from the axis, so its distance to an +// endpoint follows from the axial distance alone. +bool cylinderEndClipped(highp float axialDist, highp float radius) { + highp float axialDistFromB = axialDist - vCylinderAxis.w; + highp float radiusSq = radius * radius; + return axialDist * axialDist + radiusSq + < vCylinderClipRadii.x * vCylinderClipRadii.x || + axialDistFromB * axialDistFromB + radiusSq + < vCylinderClipRadii.y * vCylinderClipRadii.y; } RaycastHit intersectRaycastPrimitive() { RaycastRay ray = getRaycastRayThroughFragment(); + highp vec3 endpointA = vCylinderBaseCircle.xyz; + highp float radius = vCylinderBaseCircle.w; highp vec3 axisDirection = vCylinderAxis.xyz; highp float axisLength = vCylinderAxis.w; VectorSplit originSplit = - splitAlongDirection(ray.origin - vCylinderEndpointA, axisDirection); + splitAlongDirection(ray.origin - endpointA, axisDirection); VectorSplit directionSplit = splitAlongDirection(ray.direction, axisDirection); // Zero when the ray runs parallel to the axis, which never meets the lateral @@ -88,7 +99,7 @@ RaycastHit intersectRaycastPrimitive() { // that plane, so scale it onto the ray. RaycastCircleHit circleHit = intersectRaycastCircle( originSplit.perpendicular, directionSplit.perpendicular / sinAngleToAxis, - vCylinderRadius); + radius); if (!circleHit.hit) return raycastMiss(); highp float hitDist = circleHit.distAlongRay / sinAngleToAxis; @@ -96,8 +107,10 @@ RaycastHit intersectRaycastPrimitive() { highp float axialDist = originSplit.parallelDist + hitDist * directionSplit.parallelDist; if (!(axialDist >= 0.0 && axialDist <= axisLength)) return raycastMiss(); + if (cylinderEndClipped(axialDist, radius)) return raycastMiss(); highp vec3 surfacePoint = ray.origin + hitDist * ray.direction; - if (cylinderPointClipped(surfacePoint)) return raycastMiss(); + // A zero length axis passes the test above only at axialDist 0.0, which is A. + raycastCylinderAxialFraction = axisLength > 0.0 ? axialDist / axisLength : 0.0; // Open ends, so the circle normal holds everywhere. Caps would not. return makeRaycastHit(surfacePoint, circleHit.normal); diff --git a/src/webgl/raycast_primitive.browser_test.ts b/src/webgl/raycast_primitive.browser_test.ts index 2989d7439c..a0f3484ebd 100644 --- a/src/webgl/raycast_primitive.browser_test.ts +++ b/src/webgl/raycast_primitive.browser_test.ts @@ -54,6 +54,8 @@ void emitShaded() { const COVERAGE_VIEWPORT_SIZE = 64; const COVERAGE_NEAR_BOUND = 0.1; const COVERAGE_FAR_BOUND = 20; +// Radius of the tube and ball that the shaded tests draw. +const PRIMITIVE_TEST_RADIUS = "0.05"; function renderPrimitive( gl: GL, @@ -134,7 +136,8 @@ function cylinderCoverage(gl: GL, depth: number) { gl, defineRaycastCylinderShader, `emitRaycastCylinder(vec3(0.0, -0.3, ${depth.toFixed(4)}), - vec3(0.0, 0.3, ${depth.toFixed(4)}), 0.05, 0.0, 0.0);`, + vec3(0.0, 0.3, ${depth.toFixed(4)}), + ${PRIMITIVE_TEST_RADIUS}, 0.0, 0.0);`, ); } @@ -142,7 +145,7 @@ function sphereCoverage(gl: GL, depth: number) { return measureQuadCoverage( gl, defineRaycastSphereShader, - `emitRaycastSphere(vec3(0.0, 0.0, ${depth.toFixed(4)}), 0.05);`, + `emitRaycastSphere(vec3(0.0, 0.0, ${depth.toFixed(4)}), ${PRIMITIVE_TEST_RADIUS});`, ); } @@ -208,31 +211,43 @@ describe("raycast primitives", () => { }); }); + // An upright tube one unit in front of the camera, shaded with the axial + // fraction. Endpoint A is the lower end, and readPixels returns rows bottom up, + // so the result runs from endpoint A to endpoint B. Values are 0 to 255. + function shadedCylinderAxialFractionByRow( + gl: GL, + clipRadiusA: number, + clipRadiusB: number, + ): number[] { + const size = COVERAGE_VIEWPORT_SIZE; + const pixels = renderPrimitive( + gl, + defineRaycastCylinderShader, + `emitRaycastCylinder(vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0), + ${PRIMITIVE_TEST_RADIUS}, ${clipRadiusA.toFixed(4)}, + ${clipRadiusB.toFixed(4)});`, + glsl_raycastFragmentSetup + + "out_color = vec4(raycastCylinderAxialFraction, 1.0, 0.0, 1.0);\n", + ); + const fractionByRow: number[] = []; + for (let row = 0; row < size; ++row) { + for (let column = 0; column < size; ++column) { + const offset = (row * size + column) * 4; + if (pixels[offset + 1] !== 0) { + fractionByRow.push(pixels[offset]); + break; + } + } + } + return fractionByRow; + } + // A skeleton edge carries a vertex attribute at each end, and the consumer mixes // the two by this fraction. A constant value would colour a whole edge from one // endpoint, so the test checks that it runs the length of the tube. it("reports where a cylinder hit falls between the endpoints", () => { webglTest((gl) => { - const size = COVERAGE_VIEWPORT_SIZE; - const pixels = renderPrimitive( - gl, - defineRaycastCylinderShader, - `emitRaycastCylinder(vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0), - 0.05, 0.0, 0.0);`, - glsl_raycastFragmentSetup + - "out_color = vec4(raycastCylinderAxialFraction, 1.0, 0.0, 1.0);\n", - ); - // Endpoint A is the lower end, and readPixels returns rows bottom up. - const fractionByRow: number[] = []; - for (let row = 0; row < size; ++row) { - for (let column = 0; column < size; ++column) { - const offset = (row * size + column) * 4; - if (pixels[offset + 1] !== 0) { - fractionByRow.push(pixels[offset]); - break; - } - } - } + const fractionByRow = shadedCylinderAxialFractionByRow(gl, 0, 0); expect(fractionByRow.length).toBeGreaterThan(8); const [first, last] = [fractionByRow[0], fractionByRow.at(-1)!]; expect(first).toBeLessThan(16); @@ -243,6 +258,23 @@ describe("raycast primitives", () => { }); }); + // The clip radius hands the region around a joint to the ball drawn there. The + // surface sits one radius from the axis, so a clip radius of 0.15 reaches + // sqrt(0.15^2 - 0.05^2) = 0.1414 along a 0.6 long axis: the lowest 23.6 percent. + it("clips the cylinder surface around an endpoint", () => { + webglTest((gl) => { + const clipped = shadedCylinderAxialFractionByRow(gl, 0.15, 0); + expect(clipped.length).toBeGreaterThan(8); + // 0.236 of the way along, as a 0-to-255 value, is 60. + expect(clipped[0]).toBeGreaterThan(45); + expect(clipped[0]).toBeLessThan(78); + expect(clipped.at(-1)!).toBeGreaterThan(239); + // A clip radius under the tube radius cannot reach the surface at all. + const unreachable = shadedCylinderAxialFractionByRow(gl, 0.04, 0); + expect(unreachable).toEqual(shadedCylinderAxialFractionByRow(gl, 0, 0)); + }); + }); + // A radius of zero has no surface for the fragment shader to hit, and reaching // the quad emitters with one leaves the radius vectors degenerate. Both radius // helpers return zero for a point at or behind the eye, so this runs every frame diff --git a/src/webgl/raycast_sphere.ts b/src/webgl/raycast_sphere.ts index 30ef65fc28..6f8d9ce861 100644 --- a/src/webgl/raycast_sphere.ts +++ b/src/webgl/raycast_sphere.ts @@ -24,8 +24,8 @@ import type { ShaderBuilder } from "#src/webgl/shader.js"; export function defineRaycastSphereShader(builder: ShaderBuilder) { defineRaycastPrimitiveCommon(builder); - builder.addVarying("highp vec3", "vSphereCenter", "flat"); - builder.addVarying("highp float", "vSphereRadius", "flat"); + // xyz: center, w: radius. + builder.addVarying("highp vec4", "vSphere", "flat"); builder.addVertexCode(` void emitRaycastSphere(highp vec3 center, highp float radius) { // No radius, no surface to hit. A node behind the eye reaches this every frame. @@ -34,8 +34,7 @@ void emitRaycastSphere(highp vec3 center, highp float radius) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; } - vSphereCenter = center; - vSphereRadius = radius; + vSphere = vec4(center, radius); emitRaycastAabbQuad(center, vec3(radius)); } `); @@ -43,7 +42,7 @@ void emitRaycastSphere(highp vec3 center, highp float radius) { RaycastHit intersectRaycastPrimitive() { RaycastRay ray = getRaycastRayThroughFragment(); RaycastCircleHit circleHit = intersectRaycastCircle( - ray.origin - vSphereCenter, ray.direction, vSphereRadius); + ray.origin - vSphere.xyz, ray.direction, vSphere.w); if (!circleHit.hit) return raycastMiss(); return makeRaycastHit(ray.origin + circleHit.distAlongRay * ray.direction, circleHit.normal); From 0bf59869e7eac9d2101033530cdc5c3c93eea66c Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 31 Aug 2026 17:15:23 +0200 Subject: [PATCH 22/33] feat: draw cones not cylinders to avoid depth shrinking and per node radius --- src/skeleton/frontend.ts | 4 +- src/webgl/raycast_cylinder.ts | 124 +++++++++++------- src/webgl/raycast_primitive.browser_test.ts | 135 +++++++++++++++++--- src/webgl/raycast_primitive.ts | 27 ++-- 4 files changed, 214 insertions(+), 76 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 35fbb22e20..d775521a8e 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -261,9 +261,9 @@ highp vec3 vertexB = readAttribute0(aVertexIndex.y); vertexMain += ` highp vec3 displayVertexA = (uModelToDisplay * vec4(vertexA, 1.0)).xyz; highp vec3 displayVertexB = (uModelToDisplay * vec4(vertexB, 1.0)).xyz; -highp float edgeRadius = getRaycastSegmentRadiusForPixels( +highp vec2 edgeRadii = getRaycastSegmentRadiiForPixels( displayVertexA, displayVertexB, uEdgePixelRadius); -emitRaycastCylinder(displayVertexA, displayVertexB, edgeRadius, +emitRaycastCylinder(displayVertexA, displayVertexB, edgeRadii.x, edgeRadii.y, getRaycastRadiusForPixels(displayVertexA, uNodeClipPixelRadius), getRaycastRadiusForPixels(displayVertexB, uNodeClipPixelRadius)); `; diff --git a/src/webgl/raycast_cylinder.ts b/src/webgl/raycast_cylinder.ts index 012c872386..7fc477ef20 100644 --- a/src/webgl/raycast_cylinder.ts +++ b/src/webgl/raycast_cylinder.ts @@ -15,11 +15,16 @@ */ /** - * @file Raycast cylinder drawn on a camera-facing quad. The vertex stage bounds the - * cylinder with a quad and the fragment stage returns depth and a lighting factor. + * @file Raycast tube drawn on a camera-facing quad. The vertex stage bounds the + * tube with a quad and the fragment stage returns depth and a lighting factor. * - * The ends are open, because skeleton joints are drawn as spheres. Each end also - * takes a clip radius, which removes the part of the surface that the joint covers. + * The radius is given at each end and runs linearly between them, so the surface + * is a truncated cone. Equal radii give an exact cylinder. A tube sized for a + * constant on-screen width needs the taper, because the far end of a receding tube + * sits at a larger radius than the near end. + * + * The ends are open. Each end also takes a clip radius, which removes the part of + * the surface that a primitive drawn at that end covers. */ import { defineRaycastPrimitiveCommon } from "#src/webgl/raycast_primitive.js"; @@ -27,40 +32,41 @@ import type { ShaderBuilder } from "#src/webgl/shader.js"; export function defineRaycastCylinderShader(builder: ShaderBuilder) { defineRaycastPrimitiveCommon(builder); - // The cylinder is a base circle swept along an axis. xyz: center of that - // circle, which is endpoint A. w: its radius. - builder.addVarying("highp vec4", "vCylinderBaseCircle", "flat"); + builder.addVarying("highp vec3", "vCylinderEndpointA", "flat"); // xyz: unit axis direction, w: axis length. builder.addVarying("highp vec4", "vCylinderAxis", "flat"); - // x: clip radius at endpoint A, y: clip radius at endpoint B. - builder.addVarying("highp vec2", "vCylinderClipRadii", "flat"); + // xy: surface radius at endpoint A and at endpoint B. + // zw: clip radius at endpoint A and at endpoint B. + builder.addVarying("highp vec4", "vCylinderEndRadii", "flat"); builder.addVertexCode(` void emitRaycastCylinder(highp vec3 endpointA, highp vec3 endpointB, - highp float radius, + highp float radiusA, highp float radiusB, highp float clipRadiusA, highp float clipRadiusB) { + highp float widestRadius = max(radiusA, radiusB); // No radius, no surface to hit. A segment with both endpoints behind the eye // reaches this every frame. Positive form, so a non-finite radius culls too. - if (!(radius > 0.0)) { + if (!(widestRadius > 0.0)) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; } - vCylinderBaseCircle = vec4(endpointA, radius); - vCylinderClipRadii = vec2(clipRadiusA, clipRadiusB); + vCylinderEndpointA = endpointA; + vCylinderEndRadii = vec4(radiusA, radiusB, clipRadiusA, clipRadiusB); highp vec3 axisVector = endpointB - endpointA; highp float axisLength = length(axisVector); highp vec3 axisDirection = axisLength > 1e-6 ? axisVector / axisLength : vec3(0.0, 1.0, 0.0); vCylinderAxis = vec4(axisDirection, axisLength); - // Two perpendicular radius vectors spanning the circular cross-section. The - // scale by radius comes last: a zero radius would otherwise leave the second - // cross product normalising the zero vector, which GLSL ES leaves undefined. + // Two perpendicular radius vectors spanning the widest cross-section. The + // scale comes last: a zero radius would otherwise leave the second cross + // product normalising the zero vector, which GLSL ES leaves undefined. highp vec3 offAxisVector = abs(axisDirection.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0); highp vec3 unitRadiusA = normalize(cross(offAxisVector, axisDirection)); // Already unit length, being the cross product of two perpendicular unit vectors. highp vec3 unitRadiusB = cross(axisDirection, unitRadiusA); emitRaycastAxialObbQuad(endpointA, endpointB, - unitRadiusA * radius, unitRadiusB * radius); + unitRadiusA * widestRadius, + unitRadiusB * widestRadius); } `); builder.addFragmentCode(` @@ -68,52 +74,84 @@ void emitRaycastCylinder(highp vec3 endpointA, highp vec3 endpointB, // Only meaningful once intersectRaycastPrimitive has returned a hit. highp float raycastCylinderAxialFraction = 0.0; -// A surface point sits exactly one radius from the axis, so its distance to an +// A surface point sits one local radius from the axis, so its distance to an // endpoint follows from the axial distance alone. -bool cylinderEndClipped(highp float axialDist, highp float radius) { +bool cylinderEndClipped(highp float axialDist, highp float radiusAtHit) { highp float axialDistFromB = axialDist - vCylinderAxis.w; - highp float radiusSq = radius * radius; + highp float radiusSq = radiusAtHit * radiusAtHit; return axialDist * axialDist + radiusSq - < vCylinderClipRadii.x * vCylinderClipRadii.x || + < vCylinderEndRadii.z * vCylinderEndRadii.z || axialDistFromB * axialDistFromB + radiusSq - < vCylinderClipRadii.y * vCylinderClipRadii.y; + < vCylinderEndRadii.w * vCylinderEndRadii.w; } +// Across the axis the tube is a circle whose radius grows along the axis, so the +// in-plane test is a quadratic rather than the fixed-radius circle the sphere uses. +// Equal end radii leave the taper rate at zero, and this reduces to that circle. RaycastHit intersectRaycastPrimitive() { RaycastRay ray = getRaycastRayThroughFragment(); - highp vec3 endpointA = vCylinderBaseCircle.xyz; - highp float radius = vCylinderBaseCircle.w; highp vec3 axisDirection = vCylinderAxis.xyz; highp float axisLength = vCylinderAxis.w; + highp float radiusA = vCylinderEndRadii.x; + highp float inverseAxisLength = axisLength > 0.0 ? 1.0 / axisLength : 0.0; + // Radius added per unit along the axis. Zero for a plain cylinder. + highp float taperRate = (vCylinderEndRadii.y - radiusA) * inverseAxisLength; VectorSplit originSplit = - splitAlongDirection(ray.origin - endpointA, axisDirection); + splitAlongDirection(ray.origin - vCylinderEndpointA, axisDirection); VectorSplit directionSplit = splitAlongDirection(ray.direction, axisDirection); + highp float perpendicularSpeedSq = + dot(directionSplit.perpendicular, directionSplit.perpendicular); + // Radius added per unit along the ray. + highp float radiusRate = taperRate * directionSplit.parallelDist; + + // Zero for a ray along the axis, which never meets the surface. Negative for a + // ray running inside the taper angle, where the near crossing lies past the + // apex. Positive form, so a non-finite value misses. This also guards the + // divides below, since a positive value puts perpendicularSpeedSq above zero. + highp float quadraticA = perpendicularSpeedSq - radiusRate * radiusRate; + if (!(quadraticA > 0.0)) return raycastMiss(); + + // Measured from the closest approach to the axis, so that the constant term is a + // difference of two small numbers. Neuroglancer models can sit far from the + // origin, and the unshifted form subtracts two large ones. + highp float closestDist = + -dot(originSplit.perpendicular, directionSplit.perpendicular) + / perpendicularSpeedSq; + highp vec3 perpendicularAtClosest = + originSplit.perpendicular + closestDist * directionSplit.perpendicular; + highp float radiusAtClosest = radiusA + taperRate * + (originSplit.parallelDist + closestDist * directionSplit.parallelDist); - // Zero when the ray runs parallel to the axis, which never meets the lateral - // surface. GLSL ES leaves 0.0 / 0.0 undefined, so reject it before the divide. - highp float sinAngleToAxis = length(directionSplit.perpendicular); - if (!(sinAngleToAxis > 0.0)) return raycastMiss(); + // Half the linear coefficient. + highp float quadraticB = -radiusAtClosest * radiusRate; + highp float quadraticC = + dot(perpendicularAtClosest, perpendicularAtClosest) + - radiusAtClosest * radiusAtClosest; + highp float discriminant = quadraticB * quadraticB - quadraticA * quadraticC; + if (!(discriminant >= 0.0)) return raycastMiss(); - // Step 1. Across the axis the cylinder is a circle. The distance comes back in - // that plane, so scale it onto the ray. - RaycastCircleHit circleHit = intersectRaycastCircle( - originSplit.perpendicular, directionSplit.perpendicular / sinAngleToAxis, - radius); - if (!circleHit.hit) return raycastMiss(); - highp float hitDist = circleHit.distAlongRay / sinAngleToAxis; + // The near crossing. Taking the far one would fill the view from inside. + highp float hitDist = + closestDist + (-quadraticB - sqrt(discriminant)) / quadraticA; + if (!(hitDist >= 0.0)) return raycastMiss(); - // Step 2. Along the axis it is an interval. + // Along the axis the tube is an interval. That also holds the radius between the + // two end radii, so a surface past a cone apex never draws. highp float axialDist = originSplit.parallelDist + hitDist * directionSplit.parallelDist; if (!(axialDist >= 0.0 && axialDist <= axisLength)) return raycastMiss(); - if (cylinderEndClipped(axialDist, radius)) return raycastMiss(); - highp vec3 surfacePoint = ray.origin + hitDist * ray.direction; - // A zero length axis passes the test above only at axialDist 0.0, which is A. - raycastCylinderAxialFraction = axisLength > 0.0 ? axialDist / axisLength : 0.0; + highp float radiusAtHit = radiusA + taperRate * axialDist; + if (cylinderEndClipped(axialDist, radiusAtHit)) return raycastMiss(); + raycastCylinderAxialFraction = axialDist * inverseAxisLength; - // Open ends, so the circle normal holds everywhere. Caps would not. - return makeRaycastHit(surfacePoint, circleHit.normal); + // The gradient of the surface equation. The axial term is what the taper adds, + // and it vanishes for a plain cylinder, leaving the radial direction. + highp vec3 perpendicularAtHit = + originSplit.perpendicular + hitDist * directionSplit.perpendicular; + return makeRaycastHit( + ray.origin + hitDist * ray.direction, + perpendicularAtHit - radiusAtHit * taperRate * axisDirection); } `); } diff --git a/src/webgl/raycast_primitive.browser_test.ts b/src/webgl/raycast_primitive.browser_test.ts index a0f3484ebd..6f1cd14ff7 100644 --- a/src/webgl/raycast_primitive.browser_test.ts +++ b/src/webgl/raycast_primitive.browser_test.ts @@ -137,7 +137,8 @@ function cylinderCoverage(gl: GL, depth: number) { defineRaycastCylinderShader, `emitRaycastCylinder(vec3(0.0, -0.3, ${depth.toFixed(4)}), vec3(0.0, 0.3, ${depth.toFixed(4)}), - ${PRIMITIVE_TEST_RADIUS}, 0.0, 0.0);`, + ${PRIMITIVE_TEST_RADIUS}, ${PRIMITIVE_TEST_RADIUS}, + 0.0, 0.0);`, ); } @@ -161,7 +162,9 @@ describe("raycast primitives", () => { buildShader( defineRaycastCylinderShader, `emitRaycastCylinder(vec3(0.0), vec3(0.0, 1.0, 0.0), - getRaycastRadiusForPixels(vec3(0.0), 2.0), 1.0, 1.0);`, + getRaycastRadiusForPixels(vec3(0.0), 2.0), + getRaycastRadiusForPixels(vec3(0.0, 1.0, 0.0), 2.0), + 1.0, 1.0);`, ); }); @@ -183,52 +186,70 @@ describe("raycast primitives", () => { gl, defineRaycastCylinderShader, `emitRaycastCylinder(vec3(-1.0, 0.0, -0.2), vec3(1.0, 0.0, -0.2), - 0.5, 0.0, 0.0);`, + 0.5, 0.5, 0.0, 0.0);`, ); expect(coverage).toBe(0); }); }); - // This edge crosses the eye plane, so its midpoint lies behind the camera. A - // radius read there is zero and the near half of the edge is lost with it. - it("keeps an edge whose midpoint has passed behind the camera", () => { + // This edge crosses the eye plane, so one endpoint has no on-screen size and its + // own radius is zero. Borrowing the other end's radius keeps the visible half. + it("keeps an edge whose endpoint has passed behind the camera", () => { webglTest((gl) => { const endpoints = "vec3(-0.3, -0.2, -1.0), vec3(0.5, 0.4, 1.0)"; - const coverage = (radius: string) => + const coverage = (radii: string) => measureQuadCoverage( gl, defineRaycastCylinderShader, - `emitRaycastCylinder(${endpoints}, ${radius}, 0.0, 0.0);`, + `emitRaycastCylinder(${endpoints}, ${radii}, 0.0, 0.0);`, ); + // Endpoint B is behind the eye, so its own radius alone leaves nothing. expect( - coverage("getRaycastRadiusForPixels(vec3(0.1, 0.1, 0.0), 1.0)"), + coverage("0.0, getRaycastRadiusForPixels(vec3(0.5, 0.4, 1.0), 1.0)"), ).toBe(0); - const atNearEndpoint = coverage( - `getRaycastSegmentRadiusForPixels(${endpoints}, 1.0)`, + const borrowed = coverage( + `getRaycastSegmentRadiiForPixels(${endpoints}, 1.0).x, + getRaycastSegmentRadiiForPixels(${endpoints}, 1.0).y`, ); - expect(atNearEndpoint).toBeGreaterThan(0.25); - expect(atNearEndpoint).toBeLessThan(1); + expect(borrowed).toBeGreaterThan(0.25); + expect(borrowed).toBeLessThan(1); }); }); // An upright tube one unit in front of the camera, shaded with the axial // fraction. Endpoint A is the lower end, and readPixels returns rows bottom up, // so the result runs from endpoint A to endpoint B. Values are 0 to 255. - function shadedCylinderAxialFractionByRow( + function renderUprightCylinder( gl: GL, + radiusA: string, + radiusB: string, clipRadiusA: number, clipRadiusB: number, - ): number[] { - const size = COVERAGE_VIEWPORT_SIZE; - const pixels = renderPrimitive( + ): Uint8Array { + return renderPrimitive( gl, defineRaycastCylinderShader, `emitRaycastCylinder(vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0), - ${PRIMITIVE_TEST_RADIUS}, ${clipRadiusA.toFixed(4)}, + ${radiusA}, ${radiusB}, ${clipRadiusA.toFixed(4)}, ${clipRadiusB.toFixed(4)});`, glsl_raycastFragmentSetup + "out_color = vec4(raycastCylinderAxialFraction, 1.0, 0.0, 1.0);\n", ); + } + + function shadedCylinderAxialFractionByRow( + gl: GL, + clipRadiusA: number, + clipRadiusB: number, + ): number[] { + const size = COVERAGE_VIEWPORT_SIZE; + const pixels = renderUprightCylinder( + gl, + PRIMITIVE_TEST_RADIUS, + PRIMITIVE_TEST_RADIUS, + clipRadiusA, + clipRadiusB, + ); const fractionByRow: number[] = []; for (let row = 0; row < size; ++row) { for (let column = 0; column < size; ++column) { @@ -242,6 +263,25 @@ describe("raycast primitives", () => { return fractionByRow; } + // Covered pixels per row, from the endpoint A end to the endpoint B end. + function cylinderWidthByRow( + gl: GL, + radiusA: string, + radiusB: string, + ): number[] { + const size = COVERAGE_VIEWPORT_SIZE; + const pixels = renderUprightCylinder(gl, radiusA, radiusB, 0, 0); + const widthByRow: number[] = []; + for (let row = 0; row < size; ++row) { + let width = 0; + for (let column = 0; column < size; ++column) { + if (pixels[(row * size + column) * 4 + 1] !== 0) ++width; + } + if (width > 0) widthByRow.push(width); + } + return widthByRow; + } + // A skeleton edge carries a vertex attribute at each end, and the consumer mixes // the two by this fraction. A constant value would colour a whole edge from one // endpoint, so the test checks that it runs the length of the tube. @@ -258,6 +298,63 @@ describe("raycast primitives", () => { }); }); + // Equal end radii must leave the taper rate at zero, so the quadratic collapses + // to the fixed-radius circle test. A cylinder is the common case, and any drift + // here would show as a width that changes along a tube that should not taper. + it("draws an exact cylinder when both end radii match", () => { + webglTest((gl) => { + const widthByRow = cylinderWidthByRow( + gl, + PRIMITIVE_TEST_RADIUS, + PRIMITIVE_TEST_RADIUS, + ); + expect(widthByRow.length).toBeGreaterThan(8); + const widest = Math.max(...widthByRow); + const narrowest = Math.min(...widthByRow); + // One pixel covers where the silhouette falls between sample points. + expect(widest - narrowest).toBeLessThanOrEqual(1); + }); + }); + + // The taper is what holds one on-screen width along a receding edge. Endpoint A + // is the lower end here, so the drawn width has to grow from bottom to top. + // + // The rows nearest each end are left out. The ends are open, so the rim there + // projects as an ellipse and the silhouette closes over the last few rows. + it("tapers between two different end radii", () => { + webglTest((gl) => { + // Wide enough that whole-pixel rasterisation does not dominate the ratio. + const widthByRow = cylinderWidthByRow(gl, "0.03", "0.12"); + expect(widthByRow.length).toBeGreaterThan(16); + const interior = widthByRow.slice( + Math.round(widthByRow.length * 0.15), + Math.round(widthByRow.length * 0.85), + ); + // Radius runs 0.0435 to 0.1065 across this slice, a ratio of 2.45. + expect(interior.at(-1)! / interior[0]).toBeGreaterThan(1.8); + expect(interior.at(-1)! / interior[0]).toBeLessThan(3.2); + for (let i = 1; i < interior.length; ++i) { + expect(interior[i]).toBeGreaterThanOrEqual(interior[i - 1] - 1); + } + }); + }); + + // Both ends at the same depth ask for the same radius, and the requested pixel + // radius has to come back as the drawn width. This checks the whole chain from a + // pixel radius through the per-end radii to the rasterised silhouette. + it("draws a segment at the requested pixel radius", () => { + webglTest((gl) => { + const endpoints = "vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0)"; + const radii = `getRaycastSegmentRadiiForPixels(${endpoints}, 6.0)`; + const widthByRow = cylinderWidthByRow(gl, `${radii}.x`, `${radii}.y`); + expect(widthByRow.length).toBeGreaterThan(8); + // A radius of 6 device pixels is a 12 pixel width, plus or minus a pixel. + // The test above already covers the width holding along the tube. + expect(Math.max(...widthByRow)).toBeGreaterThan(10); + expect(Math.max(...widthByRow)).toBeLessThan(14); + }); + }); + // The clip radius hands the region around a joint to the ball drawn there. The // surface sits one radius from the axis, so a clip radius of 0.15 reaches // sqrt(0.15^2 - 0.05^2) = 0.1414 along a 0.6 long axis: the lowest 23.6 percent. @@ -286,7 +383,7 @@ describe("raycast primitives", () => { gl, defineRaycastCylinderShader, `emitRaycastCylinder(vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0), - 0.0, 0.0, 0.0);`, + 0.0, 0.0, 0.0, 0.0);`, ), ).toBe(0); expect( diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index d3402ad288..089324fb6c 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -279,20 +279,23 @@ highp float raycastRadiusFromClipW(highp float clipW, highp float radiusInPixels highp float getRaycastRadiusForPixels(highp vec3 point, highp float radiusInPixels) { return raycastRadiusFromClipW((uProjection * vec4(point, 1.0)).w, radiusInPixels); } -// One radius for a whole segment, read at the endpoint nearest the eye. +// A radius for each end of a segment, so that the segment holds one on-screen +// width along its whole length. A single radius cannot: the far end of a receding +// segment would draw thinner than the near end, and thinner than a node drawn +// there at the same pixel radius. // -// The midpoint drops to zero once it passes behind the eye, which loses a segment -// whose near half is still in view. The nearest endpoint also holds the radius -// below what the same endpoint yields for any wider pixel radius, so a cap sized -// that way still covers the end. -highp float getRaycastSegmentRadiusForPixels( +// x is the radius at endpointA and y the radius at endpointB. An endpoint at or +// behind the eye has no on-screen size, so it borrows the other end's radius and +// the segment draws without taper. Both behind the eye leaves both zero, which +// the emitter culls. +highp vec2 getRaycastSegmentRadiiForPixels( highp vec3 endpointA, highp vec3 endpointB, highp float radiusInPixels) { - highp float clipWA = (uProjection * vec4(endpointA, 1.0)).w; - highp float clipWB = (uProjection * vec4(endpointB, 1.0)).w; - // Both endpoints behind the eye put the whole segment behind it. - highp float nearClipW = min(clipWA, clipWB); - return raycastRadiusFromClipW( - nearClipW > 0.0 ? nearClipW : max(clipWA, clipWB), radiusInPixels); + highp vec2 radii = vec2( + getRaycastRadiusForPixels(endpointA, radiusInPixels), + getRaycastRadiusForPixels(endpointB, radiusInPixels)); + if (!(radii.x > 0.0)) radii.x = radii.y; + if (!(radii.y > 0.0)) radii.y = radii.x; + return radii; } `; From 35f8a5d2e64b1abacf718bc6ba20adb02e761874 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 31 Aug 2026 20:07:29 +0200 Subject: [PATCH 23/33] refactor: update naming and split after change to cone --- src/skeleton/frontend.ts | 10 +- src/webgl/raycast_primitive.browser_test.ts | 162 ++++++++++++------ src/webgl/raycast_primitive.ts | 41 +++-- src/webgl/raycast_shader_lib.ts | 73 ++++---- ..._cylinder.ts => raycast_truncated_cone.ts} | 84 ++++----- 5 files changed, 219 insertions(+), 151 deletions(-) rename src/webgl/{raycast_cylinder.ts => raycast_truncated_cone.ts} (67%) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index d775521a8e..e7ce588197 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -76,13 +76,13 @@ import { initializeLineShader, } from "#src/webgl/lines.js"; import { drawQuads } from "#src/webgl/quad.js"; -import { defineRaycastCylinderShader } from "#src/webgl/raycast_cylinder.js"; import { glsl_raycastFragmentSetup, initializeRaycastPrimitiveShader, projectionMatrixShaderModule, } from "#src/webgl/raycast_primitive.js"; import { defineRaycastSphereShader } from "#src/webgl/raycast_sphere.js"; +import { defineRaycastConeShader } from "#src/webgl/raycast_truncated_cone.js"; import type { ShaderBuilder, ShaderProgram, @@ -255,7 +255,7 @@ highp vec3 vertexA = readAttribute0(aVertexIndex.x); highp vec3 vertexB = readAttribute0(aVertexIndex.y); `; if (useRaycast) { - defineRaycastCylinderShader(builder); + defineRaycastConeShader(builder); builder.addUniform("highp float", "uEdgePixelRadius"); builder.addUniform("highp mat4", "uModelToDisplay"); vertexMain += ` @@ -263,7 +263,7 @@ highp vec3 displayVertexA = (uModelToDisplay * vec4(vertexA, 1.0)).xyz; highp vec3 displayVertexB = (uModelToDisplay * vec4(vertexB, 1.0)).xyz; highp vec2 edgeRadii = getRaycastSegmentRadiiForPixels( displayVertexA, displayVertexB, uEdgePixelRadius); -emitRaycastCylinder(displayVertexA, displayVertexB, edgeRadii.x, edgeRadii.y, +emitRaycastCone(displayVertexA, displayVertexB, edgeRadii.x, edgeRadii.y, getRaycastRadiusForPixels(displayVertexA, uNodeClipPixelRadius), getRaycastRadiusForPixels(displayVertexB, uNodeClipPixelRadius)); `; @@ -298,7 +298,7 @@ void emitDefault() { shaderBuilderState, vertexMain, useRaycast, - useRaycast ? "raycastCylinderAxialFraction" : undefined, + useRaycast ? "raycastConeAxialFraction" : undefined, ); } @@ -356,7 +356,7 @@ void emitDefault() { } // `edgeMixExpression` is set only where one draw covers a whole edge, as the - // cylinder does. A vertex attribute has a value at each end, and the expression + // cone does. A vertex attribute has a value at each end, and the expression // gives where the fragment falls between them. Without it every fragment of an // edge would read the same end. private finalizeShaderBuilder( diff --git a/src/webgl/raycast_primitive.browser_test.ts b/src/webgl/raycast_primitive.browser_test.ts index 6f1cd14ff7..629b55fa3a 100644 --- a/src/webgl/raycast_primitive.browser_test.ts +++ b/src/webgl/raycast_primitive.browser_test.ts @@ -18,12 +18,12 @@ import { describe, expect, it } from "vitest"; import { mat4 } from "#src/util/geom.js"; import type { GL } from "#src/webgl/context.js"; import { drawQuads } from "#src/webgl/quad.js"; -import { defineRaycastCylinderShader } from "#src/webgl/raycast_cylinder.js"; import { glsl_raycastFragmentSetup, initializeRaycastPrimitiveShader, } from "#src/webgl/raycast_primitive.js"; import { defineRaycastSphereShader } from "#src/webgl/raycast_sphere.js"; +import { defineRaycastConeShader } from "#src/webgl/raycast_truncated_cone.js"; import { ShaderBuilder } from "#src/webgl/shader.js"; import { webglTest } from "#src/webgl/testing.js"; import { defineVertexId, VertexIdHelper } from "#src/webgl/vertex_id.js"; @@ -54,7 +54,7 @@ void emitShaded() { const COVERAGE_VIEWPORT_SIZE = 64; const COVERAGE_NEAR_BOUND = 0.1; const COVERAGE_FAR_BOUND = 20; -// Radius of the tube and ball that the shaded tests draw. +// Radius of the cone and ball that the shaded tests draw. const PRIMITIVE_TEST_RADIUS = "0.05"; function renderPrimitive( @@ -130,12 +130,34 @@ function measureQuadCoverage( return covered / (size * size); } +// Fraction of the viewport the primitive's own surface shades, with the real +// fragment setup so a miss discards. Unlike quad coverage this measures the +// surface, so it falls if a bounding quad clips the primitive. +function measureShadedCoverage( + gl: GL, + definePrimitive: (builder: ShaderBuilder) => void, + emitPrimitive: string, +): number { + const pixels = renderPrimitive( + gl, + definePrimitive, + emitPrimitive, + glsl_raycastFragmentSetup + "out_color = vec4(1.0, 1.0, 1.0, 1.0);\n", + ); + const size = COVERAGE_VIEWPORT_SIZE; + let shaded = 0; + for (let i = 0; i < size * size; ++i) { + if (pixels[i * 4] !== 0) ++shaded; + } + return shaded / (size * size); +} + // `depth` is the raycast-space z, negative for in front of the camera. -function cylinderCoverage(gl: GL, depth: number) { +function coneCoverage(gl: GL, depth: number) { return measureQuadCoverage( gl, - defineRaycastCylinderShader, - `emitRaycastCylinder(vec3(0.0, -0.3, ${depth.toFixed(4)}), + defineRaycastConeShader, + `emitRaycastCone(vec3(0.0, -0.3, ${depth.toFixed(4)}), vec3(0.0, 0.3, ${depth.toFixed(4)}), ${PRIMITIVE_TEST_RADIUS}, ${PRIMITIVE_TEST_RADIUS}, 0.0, 0.0);`, @@ -158,34 +180,34 @@ describe("raycast primitives", () => { ); }); - it("compiles the cylinder shader", () => { + it("compiles the cone shader", () => { buildShader( - defineRaycastCylinderShader, - `emitRaycastCylinder(vec3(0.0), vec3(0.0, 1.0, 0.0), + defineRaycastConeShader, + `emitRaycastCone(vec3(0.0), vec3(0.0, 1.0, 0.0), getRaycastRadiusForPixels(vec3(0.0), 2.0), getRaycastRadiusForPixels(vec3(0.0, 1.0, 0.0), 2.0), 1.0, 1.0);`, ); }); - it("bounds a cylinder tightly, and culls one behind the camera", () => { + it("bounds a cone tightly, and culls one behind the camera", () => { webglTest((gl) => { - const visible = cylinderCoverage(gl, -1); + const visible = coneCoverage(gl, -1); expect(visible).toBeGreaterThan(0); expect(visible).toBeLessThan(0.5); - expect(cylinderCoverage(gl, 1)).toBe(0); + expect(coneCoverage(gl, 1)).toBe(0); }); }); - // The camera sits inside this tube, whose surface then has no bounded screen + // The camera sits inside this cone, whose surface then has no bounded screen // footprint. Covering the viewport instead would shade every pixel of a // depth-writing fragment shader, once for each such edge. - it("culls a cylinder that wraps the camera", () => { + it("culls a cone that wraps the camera", () => { webglTest((gl) => { const coverage = measureQuadCoverage( gl, - defineRaycastCylinderShader, - `emitRaycastCylinder(vec3(-1.0, 0.0, -0.2), vec3(1.0, 0.0, -0.2), + defineRaycastConeShader, + `emitRaycastCone(vec3(-1.0, 0.0, -0.2), vec3(1.0, 0.0, -0.2), 0.5, 0.5, 0.0, 0.0);`, ); expect(coverage).toBe(0); @@ -200,8 +222,8 @@ describe("raycast primitives", () => { const coverage = (radii: string) => measureQuadCoverage( gl, - defineRaycastCylinderShader, - `emitRaycastCylinder(${endpoints}, ${radii}, 0.0, 0.0);`, + defineRaycastConeShader, + `emitRaycastCone(${endpoints}, ${radii}, 0.0, 0.0);`, ); // Endpoint B is behind the eye, so its own radius alone leaves nothing. expect( @@ -216,10 +238,10 @@ describe("raycast primitives", () => { }); }); - // An upright tube one unit in front of the camera, shaded with the axial + // An upright cone one unit in front of the camera, shaded with the axial // fraction. Endpoint A is the lower end, and readPixels returns rows bottom up, // so the result runs from endpoint A to endpoint B. Values are 0 to 255. - function renderUprightCylinder( + function renderUprightCone( gl: GL, radiusA: string, radiusB: string, @@ -228,22 +250,22 @@ describe("raycast primitives", () => { ): Uint8Array { return renderPrimitive( gl, - defineRaycastCylinderShader, - `emitRaycastCylinder(vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0), + defineRaycastConeShader, + `emitRaycastCone(vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0), ${radiusA}, ${radiusB}, ${clipRadiusA.toFixed(4)}, ${clipRadiusB.toFixed(4)});`, glsl_raycastFragmentSetup + - "out_color = vec4(raycastCylinderAxialFraction, 1.0, 0.0, 1.0);\n", + "out_color = vec4(raycastConeAxialFraction, 1.0, 0.0, 1.0);\n", ); } - function shadedCylinderAxialFractionByRow( + function shadedConeAxialFractionByRow( gl: GL, clipRadiusA: number, clipRadiusB: number, ): number[] { const size = COVERAGE_VIEWPORT_SIZE; - const pixels = renderUprightCylinder( + const pixels = renderUprightCone( gl, PRIMITIVE_TEST_RADIUS, PRIMITIVE_TEST_RADIUS, @@ -264,13 +286,9 @@ describe("raycast primitives", () => { } // Covered pixels per row, from the endpoint A end to the endpoint B end. - function cylinderWidthByRow( - gl: GL, - radiusA: string, - radiusB: string, - ): number[] { + function coneWidthByRow(gl: GL, radiusA: string, radiusB: string): number[] { const size = COVERAGE_VIEWPORT_SIZE; - const pixels = renderUprightCylinder(gl, radiusA, radiusB, 0, 0); + const pixels = renderUprightCone(gl, radiusA, radiusB, 0, 0); const widthByRow: number[] = []; for (let row = 0; row < size; ++row) { let width = 0; @@ -284,10 +302,10 @@ describe("raycast primitives", () => { // A skeleton edge carries a vertex attribute at each end, and the consumer mixes // the two by this fraction. A constant value would colour a whole edge from one - // endpoint, so the test checks that it runs the length of the tube. - it("reports where a cylinder hit falls between the endpoints", () => { + // endpoint, so the test checks that it runs the length of the cone. + it("reports where a cone hit falls between the endpoints", () => { webglTest((gl) => { - const fractionByRow = shadedCylinderAxialFractionByRow(gl, 0, 0); + const fractionByRow = shadedConeAxialFractionByRow(gl, 0, 0); expect(fractionByRow.length).toBeGreaterThan(8); const [first, last] = [fractionByRow[0], fractionByRow.at(-1)!]; expect(first).toBeLessThan(16); @@ -300,10 +318,10 @@ describe("raycast primitives", () => { // Equal end radii must leave the taper rate at zero, so the quadratic collapses // to the fixed-radius circle test. A cylinder is the common case, and any drift - // here would show as a width that changes along a tube that should not taper. + // here would show as a width that changes along a cone that should not taper. it("draws an exact cylinder when both end radii match", () => { webglTest((gl) => { - const widthByRow = cylinderWidthByRow( + const widthByRow = coneWidthByRow( gl, PRIMITIVE_TEST_RADIUS, PRIMITIVE_TEST_RADIUS, @@ -324,7 +342,7 @@ describe("raycast primitives", () => { it("tapers between two different end radii", () => { webglTest((gl) => { // Wide enough that whole-pixel rasterisation does not dominate the ratio. - const widthByRow = cylinderWidthByRow(gl, "0.03", "0.12"); + const widthByRow = coneWidthByRow(gl, "0.03", "0.12"); expect(widthByRow.length).toBeGreaterThan(16); const interior = widthByRow.slice( Math.round(widthByRow.length * 0.15), @@ -346,10 +364,10 @@ describe("raycast primitives", () => { webglTest((gl) => { const endpoints = "vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0)"; const radii = `getRaycastSegmentRadiiForPixels(${endpoints}, 6.0)`; - const widthByRow = cylinderWidthByRow(gl, `${radii}.x`, `${radii}.y`); + const widthByRow = coneWidthByRow(gl, `${radii}.x`, `${radii}.y`); expect(widthByRow.length).toBeGreaterThan(8); // A radius of 6 device pixels is a 12 pixel width, plus or minus a pixel. - // The test above already covers the width holding along the tube. + // The test above already covers the width holding along the cone. expect(Math.max(...widthByRow)).toBeGreaterThan(10); expect(Math.max(...widthByRow)).toBeLessThan(14); }); @@ -358,17 +376,17 @@ describe("raycast primitives", () => { // The clip radius hands the region around a joint to the ball drawn there. The // surface sits one radius from the axis, so a clip radius of 0.15 reaches // sqrt(0.15^2 - 0.05^2) = 0.1414 along a 0.6 long axis: the lowest 23.6 percent. - it("clips the cylinder surface around an endpoint", () => { + it("clips the cone surface around an endpoint", () => { webglTest((gl) => { - const clipped = shadedCylinderAxialFractionByRow(gl, 0.15, 0); + const clipped = shadedConeAxialFractionByRow(gl, 0.15, 0); expect(clipped.length).toBeGreaterThan(8); // 0.236 of the way along, as a 0-to-255 value, is 60. expect(clipped[0]).toBeGreaterThan(45); expect(clipped[0]).toBeLessThan(78); expect(clipped.at(-1)!).toBeGreaterThan(239); - // A clip radius under the tube radius cannot reach the surface at all. - const unreachable = shadedCylinderAxialFractionByRow(gl, 0.04, 0); - expect(unreachable).toEqual(shadedCylinderAxialFractionByRow(gl, 0, 0)); + // A clip radius under the cone radius cannot reach the surface at all. + const unreachable = shadedConeAxialFractionByRow(gl, 0.04, 0); + expect(unreachable).toEqual(shadedConeAxialFractionByRow(gl, 0, 0)); }); }); @@ -381,8 +399,8 @@ describe("raycast primitives", () => { expect( measureQuadCoverage( gl, - defineRaycastCylinderShader, - `emitRaycastCylinder(vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0), + defineRaycastConeShader, + `emitRaycastCone(vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0), 0.0, 0.0, 0.0, 0.0);`, ), ).toBe(0); @@ -396,12 +414,60 @@ describe("raycast primitives", () => { }); }); - it("bounds a sphere tightly, and culls one behind the camera", () => { + // The bound is the silhouette disc, not a projected box, so the quad is the + // square around that disc and needs no slack margin. A radius of 0.05 one unit + // ahead has a silhouette 3.86 pixels across of a 64 pixel viewport, which is + // 0.0115 of it. The square around a disc costs 4 / pi, and the angle margin costs + // 1.04 twice, so the quad should land near 0.0157. The projected box it replaced + // measured 0.0376, most of that its two pixel margin. + it("bounds a sphere to its silhouette, and culls one behind the camera", () => { webglTest((gl) => { const visible = sphereCoverage(gl, -1); - expect(visible).toBeGreaterThan(0); - expect(visible).toBeLessThan(0.5); + expect(visible).toBeGreaterThan(0.011); + expect(visible).toBeLessThan(0.02); expect(sphereCoverage(gl, 1)).toBe(0); }); }); + + // A tighter bound only pays if it still contains the whole surface. The exact + // silhouette of a sphere of radius r at distance d has radius r / sqrt(d^2 - r^2), + // which for r of 0.2 at one unit is 4 percent more area than the r / d disc the + // bound is built from. The angle margin covers that, so the shaded surface has to + // exceed the plain disc rather than fall short of it. + it("bounds a sphere without clipping its surface", () => { + webglTest((gl) => { + const shaded = measureShadedCoverage( + gl, + defineRaycastSphereShader, + "emitRaycastSphere(vec3(0.0, 0.0, -1.0), 0.2);", + ); + // A radius of 0.2 one unit ahead spans 15.5 pixels of a 64 pixel viewport, + // so the r / d disc is 0.1831 of it. + expect(shaded).toBeGreaterThan(0.1831); + expect(shaded).toBeLessThan(0.21); + }); + }); + + // Above the angle threshold the bound stops being finite and the projected box + // takes over. Nothing may be lost at that switch, so the shaded surface has to + // keep following the radius across it. + it("loses no surface where the sphere bound falls back to the box", () => { + webglTest((gl) => { + const shadedAtRadius = (radius: string) => + measureShadedCoverage( + gl, + defineRaycastSphereShader, + `emitRaycastSphere(vec3(0.0, 0.0, -1.0), ${radius});`, + ); + // The threshold is a silhouette sine of 0.25, which at one unit is a radius + // of 0.25. These two straddle it. + const belowThreshold = shadedAtRadius("0.24"); + const aboveThreshold = shadedAtRadius("0.26"); + expect(belowThreshold).toBeGreaterThan(0); + // Area follows the radius squared, so 0.26 over 0.24 predicts 1.174. + const ratio = aboveThreshold / belowThreshold; + expect(ratio).toBeGreaterThan(1.08); + expect(ratio).toBeLessThan(1.28); + }); + }); }); diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index 089324fb6c..ebc505933d 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -26,20 +26,23 @@ * true sphere in raycast space. `uLightDirection` is read in the same space, so * the surface normal needs no further transform. * - * `emitRaycastAabbQuad` and `emitRaycastAxialObbQuad` bound a primitive for - * rasterisation by bounding the object in raycast space - then projecting to - * screen space and emit the screen space quad which covers the - * projected bounding box. - * Use the AABB (axis aligned bounding box) for objects like spheres, cubes - * and other fairly uniform geometries. - * Use the axial OBB (oriented bounding box) for objects with one defined long - * axis, like cylinders, capsules, cones, etc. + * Two bounding strategies live here, and a primitive opts into the one it uses by + * calling `defineRaycastAabbQuad` or `defineRaycastAxialObbQuad`. Both bound the + * object in raycast space, project that bound to screen space, and emit a quad + * covering it. Neither is tied to one shape. + * `emitRaycastAabbQuad` takes a box, so it suits a roughly uniform object and it + * serves as the fallback wherever a tighter bound stops being finite. + * `emitRaycastAxialObbQuad` takes a segment and two radius vectors, so it suits an + * object with one long axis: a cone, a capsule, a cylinder. + * + * A primitive whose own silhouette is cheap to bound exactly should do that in its + * own file instead. `raycast_sphere.ts` does. */ import { mat4 } from "#src/util/geom.js"; import { glsl_getQuadVertexPosition } from "#src/webgl/quad.js"; import { - glsl_intersectRaycastCircle, + glsl_nearQuadraticRoot, glsl_splitAlongDirection, } from "#src/webgl/raycast_shader_lib.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; @@ -310,21 +313,31 @@ raycastSurfaceDepth = raycastHit.windowDepth; raycastLightingFactor = raycastHit.lightingFactor; `; -export function defineRaycastPrimitiveCommon(builder: ShaderBuilder) { +// Everything a raycast primitive needs whatever shape it draws, and whatever bound +// it uses. A ShaderModule, so requiring it twice adds its code once. +export function raycastPrimitiveCoreModule(builder: ShaderBuilder) { builder.require(projectionMatrixShaderModule); builder.addUniform("highp mat4", "uInvProjection"); builder.addUniform("highp vec4", "uLightDirection"); builder.addUniform("highp vec2", "uViewportSize"); builder.addVertexCode(glsl_getQuadVertexPosition); - builder.addVertexCode(glsl_clipLineToDepthRange); builder.addVertexCode(glsl_raycastDepthRangeCull); builder.addVertexCode(glsl_raycastQuadConstants); - builder.addVertexCode(glsl_raycastAabbQuad); - builder.addVertexCode(glsl_raycastAxialObbQuad); builder.addVertexCode(glsl_raycastPrimitivePixelRadius); builder.addFragmentCode(glsl_raycastPrimitiveFragmentUtil); builder.addFragmentCode(glsl_splitAlongDirection); - builder.addFragmentCode(glsl_intersectRaycastCircle); + builder.addFragmentCode(glsl_nearQuadraticRoot); +} + +export function defineRaycastAabbQuad(builder: ShaderBuilder) { + builder.require(raycastPrimitiveCoreModule); + builder.addVertexCode(glsl_raycastAabbQuad); +} + +export function defineRaycastAxialObbQuad(builder: ShaderBuilder) { + builder.require(raycastPrimitiveCoreModule); + builder.addVertexCode(glsl_clipLineToDepthRange); + builder.addVertexCode(glsl_raycastAxialObbQuad); } const tempInvProjection = mat4.create(); diff --git a/src/webgl/raycast_shader_lib.ts b/src/webgl/raycast_shader_lib.ts index eb637e0ac0..0d4a843902 100644 --- a/src/webgl/raycast_shader_lib.ts +++ b/src/webgl/raycast_shader_lib.ts @@ -15,9 +15,10 @@ */ /** - * @file Small GLSL geometry helpers for ray casting. + * @file General GLSL algebra for ray casting against a quadric surface. * - * `intersectRaycastCircle` is adapted from Inigo Quilez's sphere intersector + * `nearQuadraticRoot`, and the way callers form the coefficients they pass it, are + * adapted from Inigo Quilez's sphere intersector * (https://iquilezles.org/articles/intersectors/), MIT licensed: * * The MIT License. Copyright (c) 2016 Inigo Quilez. @@ -30,9 +31,11 @@ * notice and this permission notice shall be included in all copies or * substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS". * - * The hit test subtracts the perpendicular distance from the radius rather than - * forming the original `c = dot(oc, oc) - r * r`. Neuroglancer models can sit far - * from the origin, and the rearranged form never subtracts two large numbers. + * One change. A caller measures its parameter from the ray's closest approach to + * the surface's axis and subtracts the perpendicular distance from the radius, + * rather than forming the original `c = dot(oc, oc) - r * r`. Neuroglancer models + * can sit far from the origin, and the rearranged form never subtracts two large + * numbers. */ export const glsl_splitAlongDirection = ` @@ -49,44 +52,30 @@ VectorSplit splitAlongDirection(highp vec3 vectorToSplit, highp vec3 unitDirecti } `; -export const glsl_intersectRaycastCircle = ` -struct RaycastCircleHit { - bool hit; - // To the near crossing. - highp float distAlongRay; - // Hit point minus centre, not normalised. - highp vec3 normal; +export const glsl_nearQuadraticRoot = ` +struct QuadraticNearRoot { + bool exists; + highp float value; }; -RaycastCircleHit raycastCircleMiss() { - RaycastCircleHit hit; - hit.hit = false; - return hit; -} - -// Also a sphere test, when the vectors are not confined to one plane. -RaycastCircleHit intersectRaycastCircle(highp vec3 centerToOrigin, - highp vec3 unitDirection, - highp float radius) { - VectorSplit originSplit = splitAlongDirection(centerToOrigin, unitDirection); - highp float radiusSq = radius * radius; - highp float perpendicularDistSq = - dot(originSplit.perpendicular, originSplit.perpendicular); - - // Positive form so that a NaN falls through to the miss. IEEE floats guarantee - // that, GLSL ES does not, so this is defence and not a promise. - if (!(perpendicularDistSq <= radiusSq)) return raycastCircleMiss(); - - highp float halfChord = sqrt(radiusSq - perpendicularDistSq); - // Taking the far crossing instead would fill the view when the camera clips - // inside the geometry. - highp float distAlongRay = -originSplit.parallelDist - halfChord; - if (!(distAlongRay >= 0.0)) return raycastCircleMiss(); - - RaycastCircleHit hit; - hit.hit = true; - hit.distAlongRay = distAlongRay; - hit.normal = originSplit.perpendicular - halfChord * unitDirection; - return hit; +// Smaller root of quadraticA * t^2 + 2 * quadraticB * t + quadraticC, for a +// quadraticA above zero. quadraticB is half the linear coefficient, which is the +// form a ray against a quadric produces and which keeps the discriminant free of a +// factor of four. +QuadraticNearRoot nearQuadraticRoot(highp float quadraticA, highp float quadraticB, + highp float quadraticC) { + highp float discriminant = quadraticB * quadraticB - quadraticA * quadraticC; + QuadraticNearRoot root; + // Positive form so that a NaN falls through to no root, and so that sqrt is + // never reached with a negative argument. IEEE floats guarantee the NaN half, + // GLSL ES does not, so that part is defence and not a promise. + if (!(discriminant >= 0.0)) { + root.exists = false; + root.value = 0.0; + return root; + } + root.exists = true; + root.value = (-quadraticB - sqrt(discriminant)) / quadraticA; + return root; } `; diff --git a/src/webgl/raycast_cylinder.ts b/src/webgl/raycast_truncated_cone.ts similarity index 67% rename from src/webgl/raycast_cylinder.ts rename to src/webgl/raycast_truncated_cone.ts index 7fc477ef20..52d4e92130 100644 --- a/src/webgl/raycast_cylinder.ts +++ b/src/webgl/raycast_truncated_cone.ts @@ -15,31 +15,33 @@ */ /** - * @file Raycast tube drawn on a camera-facing quad. The vertex stage bounds the - * tube with a quad and the fragment stage returns depth and a lighting factor. + * @file Raycast truncated cone drawn on a camera-facing quad. The vertex stage + * bounds the cone with a quad and the fragment stage returns depth and a lighting + * factor. Symbols below say cone for brevity; the surface is always the truncated + * one, and its ends are open. * - * The radius is given at each end and runs linearly between them, so the surface - * is a truncated cone. Equal radii give an exact cylinder. A tube sized for a - * constant on-screen width needs the taper, because the far end of a receding tube - * sits at a larger radius than the near end. + * The radius is given at each end and runs linearly between them. Equal radii give + * an exact cylinder, which is the common case. A cone sized for a constant + * on-screen width needs the taper, because the far end of a receding cone sits at a + * larger radius than the near end. * - * The ends are open. Each end also takes a clip radius, which removes the part of - * the surface that a primitive drawn at that end covers. + * Each end also takes a clip radius, which removes the part of the surface that a + * primitive drawn at that end covers. */ -import { defineRaycastPrimitiveCommon } from "#src/webgl/raycast_primitive.js"; +import { defineRaycastAxialObbQuad } from "#src/webgl/raycast_primitive.js"; import type { ShaderBuilder } from "#src/webgl/shader.js"; -export function defineRaycastCylinderShader(builder: ShaderBuilder) { - defineRaycastPrimitiveCommon(builder); - builder.addVarying("highp vec3", "vCylinderEndpointA", "flat"); +export function defineRaycastConeShader(builder: ShaderBuilder) { + defineRaycastAxialObbQuad(builder); + builder.addVarying("highp vec3", "vConeEndpointA", "flat"); // xyz: unit axis direction, w: axis length. - builder.addVarying("highp vec4", "vCylinderAxis", "flat"); + builder.addVarying("highp vec4", "vConeAxis", "flat"); // xy: surface radius at endpoint A and at endpoint B. // zw: clip radius at endpoint A and at endpoint B. - builder.addVarying("highp vec4", "vCylinderEndRadii", "flat"); + builder.addVarying("highp vec4", "vConeEndRadii", "flat"); builder.addVertexCode(` -void emitRaycastCylinder(highp vec3 endpointA, highp vec3 endpointB, +void emitRaycastCone(highp vec3 endpointA, highp vec3 endpointB, highp float radiusA, highp float radiusB, highp float clipRadiusA, highp float clipRadiusB) { highp float widestRadius = max(radiusA, radiusB); @@ -49,12 +51,12 @@ void emitRaycastCylinder(highp vec3 endpointA, highp vec3 endpointB, gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; } - vCylinderEndpointA = endpointA; - vCylinderEndRadii = vec4(radiusA, radiusB, clipRadiusA, clipRadiusB); + vConeEndpointA = endpointA; + vConeEndRadii = vec4(radiusA, radiusB, clipRadiusA, clipRadiusB); highp vec3 axisVector = endpointB - endpointA; highp float axisLength = length(axisVector); highp vec3 axisDirection = axisLength > 1e-6 ? axisVector / axisLength : vec3(0.0, 1.0, 0.0); - vCylinderAxis = vec4(axisDirection, axisLength); + vConeAxis = vec4(axisDirection, axisLength); // Two perpendicular radius vectors spanning the widest cross-section. The // scale comes last: a zero radius would otherwise leave the second cross @@ -72,33 +74,33 @@ void emitRaycastCylinder(highp vec3 endpointA, highp vec3 endpointB, builder.addFragmentCode(` // Where the surface point falls between the endpoints, 0.0 at A and 1.0 at B. // Only meaningful once intersectRaycastPrimitive has returned a hit. -highp float raycastCylinderAxialFraction = 0.0; +highp float raycastConeAxialFraction = 0.0; // A surface point sits one local radius from the axis, so its distance to an // endpoint follows from the axial distance alone. -bool cylinderEndClipped(highp float axialDist, highp float radiusAtHit) { - highp float axialDistFromB = axialDist - vCylinderAxis.w; +bool coneEndClipped(highp float axialDist, highp float radiusAtHit) { + highp float axialDistFromB = axialDist - vConeAxis.w; highp float radiusSq = radiusAtHit * radiusAtHit; return axialDist * axialDist + radiusSq - < vCylinderEndRadii.z * vCylinderEndRadii.z || + < vConeEndRadii.z * vConeEndRadii.z || axialDistFromB * axialDistFromB + radiusSq - < vCylinderEndRadii.w * vCylinderEndRadii.w; + < vConeEndRadii.w * vConeEndRadii.w; } -// Across the axis the tube is a circle whose radius grows along the axis, so the +// Across the axis the cone is a circle whose radius grows along the axis, so the // in-plane test is a quadratic rather than the fixed-radius circle the sphere uses. // Equal end radii leave the taper rate at zero, and this reduces to that circle. RaycastHit intersectRaycastPrimitive() { RaycastRay ray = getRaycastRayThroughFragment(); - highp vec3 axisDirection = vCylinderAxis.xyz; - highp float axisLength = vCylinderAxis.w; - highp float radiusA = vCylinderEndRadii.x; + highp vec3 axisDirection = vConeAxis.xyz; + highp float axisLength = vConeAxis.w; + highp float radiusA = vConeEndRadii.x; highp float inverseAxisLength = axisLength > 0.0 ? 1.0 / axisLength : 0.0; - // Radius added per unit along the axis. Zero for a plain cylinder. - highp float taperRate = (vCylinderEndRadii.y - radiusA) * inverseAxisLength; + // Radius added per unit along the axis. Zero for an exact cylinder. + highp float taperRate = (vConeEndRadii.y - radiusA) * inverseAxisLength; VectorSplit originSplit = - splitAlongDirection(ray.origin - vCylinderEndpointA, axisDirection); + splitAlongDirection(ray.origin - vConeEndpointA, axisDirection); VectorSplit directionSplit = splitAlongDirection(ray.direction, axisDirection); highp float perpendicularSpeedSq = dot(directionSplit.perpendicular, directionSplit.perpendicular); @@ -123,30 +125,28 @@ RaycastHit intersectRaycastPrimitive() { highp float radiusAtClosest = radiusA + taperRate * (originSplit.parallelDist + closestDist * directionSplit.parallelDist); - // Half the linear coefficient. - highp float quadraticB = -radiusAtClosest * radiusRate; - highp float quadraticC = + QuadraticNearRoot root = nearQuadraticRoot( + quadraticA, + -radiusAtClosest * radiusRate, dot(perpendicularAtClosest, perpendicularAtClosest) - - radiusAtClosest * radiusAtClosest; - highp float discriminant = quadraticB * quadraticB - quadraticA * quadraticC; - if (!(discriminant >= 0.0)) return raycastMiss(); + - radiusAtClosest * radiusAtClosest); + if (!root.exists) return raycastMiss(); // The near crossing. Taking the far one would fill the view from inside. - highp float hitDist = - closestDist + (-quadraticB - sqrt(discriminant)) / quadraticA; + highp float hitDist = closestDist + root.value; if (!(hitDist >= 0.0)) return raycastMiss(); - // Along the axis the tube is an interval. That also holds the radius between the + // Along the axis the cone is an interval. That also holds the radius between the // two end radii, so a surface past a cone apex never draws. highp float axialDist = originSplit.parallelDist + hitDist * directionSplit.parallelDist; if (!(axialDist >= 0.0 && axialDist <= axisLength)) return raycastMiss(); highp float radiusAtHit = radiusA + taperRate * axialDist; - if (cylinderEndClipped(axialDist, radiusAtHit)) return raycastMiss(); - raycastCylinderAxialFraction = axialDist * inverseAxisLength; + if (coneEndClipped(axialDist, radiusAtHit)) return raycastMiss(); + raycastConeAxialFraction = axialDist * inverseAxisLength; // The gradient of the surface equation. The axial term is what the taper adds, - // and it vanishes for a plain cylinder, leaving the radial direction. + // and it vanishes for an exact cylinder, leaving the radial direction. highp vec3 perpendicularAtHit = originSplit.perpendicular + hitDist * directionSplit.perpendicular; return makeRaycastHit( From 03d176bbd6ef5bbe9254aa27a57c0326c2d1cb33 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 31 Aug 2026 21:55:06 +0200 Subject: [PATCH 24/33] feat: use sphere projection from Quilez for tighter bound --- src/webgl/raycast_primitive.browser_test.ts | 51 ++++----- src/webgl/raycast_primitive.ts | 87 ++------------- src/webgl/raycast_sphere.ts | 114 ++++++++++++++++++-- 3 files changed, 138 insertions(+), 114 deletions(-) diff --git a/src/webgl/raycast_primitive.browser_test.ts b/src/webgl/raycast_primitive.browser_test.ts index 629b55fa3a..5b821be07d 100644 --- a/src/webgl/raycast_primitive.browser_test.ts +++ b/src/webgl/raycast_primitive.browser_test.ts @@ -414,26 +414,25 @@ describe("raycast primitives", () => { }); }); - // The bound is the silhouette disc, not a projected box, so the quad is the - // square around that disc and needs no slack margin. A radius of 0.05 one unit - // ahead has a silhouette 3.86 pixels across of a 64 pixel viewport, which is - // 0.0115 of it. The square around a disc costs 4 / pi, and the angle margin costs - // 1.04 twice, so the quad should land near 0.0157. The projected box it replaced - // measured 0.0376, most of that its two pixel margin. + // The bound is the exact silhouette conic, so the quad is the square around that + // ellipse and needs no margin. A radius of 0.05 one unit ahead has a silhouette + // 0.12086 in NDC, which is 3.87 pixels of a 64 pixel viewport, so the square is + // 59.8 pixels or 0.0146 of it. The disc itself is 0.0115. The projected box this + // replaced measured 0.0376, most of that its fixed two pixel margin. it("bounds a sphere to its silhouette, and culls one behind the camera", () => { webglTest((gl) => { const visible = sphereCoverage(gl, -1); - expect(visible).toBeGreaterThan(0.011); - expect(visible).toBeLessThan(0.02); + expect(visible).toBeGreaterThan(0.012); + expect(visible).toBeLessThan(0.017); expect(sphereCoverage(gl, 1)).toBe(0); }); }); // A tighter bound only pays if it still contains the whole surface. The exact // silhouette of a sphere of radius r at distance d has radius r / sqrt(d^2 - r^2), - // which for r of 0.2 at one unit is 4 percent more area than the r / d disc the - // bound is built from. The angle margin covers that, so the shaded surface has to - // exceed the plain disc rather than fall short of it. + // which for r of 0.2 at one unit is 4 percent more area than the r / d disc. The + // conic gives that exactly, so the shaded surface has to exceed the plain disc + // rather than fall short of it, which is what a quad clipping the sphere would do. it("bounds a sphere without clipping its surface", () => { webglTest((gl) => { const shaded = measureShadedCoverage( @@ -448,26 +447,18 @@ describe("raycast primitives", () => { }); }); - // Above the angle threshold the bound stops being finite and the projected box - // takes over. Nothing may be lost at that switch, so the shaded surface has to - // keep following the radius across it. - it("loses no surface where the sphere bound falls back to the box", () => { + // The conic is an ellipse only while the sphere clears the eye plane. Past that, + // part of the sphere projects arbitrarily far, so the whole viewport is the only + // honest bound, and nothing may be lost by taking it. + it("takes the whole viewport when the sphere crosses the eye plane", () => { webglTest((gl) => { - const shadedAtRadius = (radius: string) => - measureShadedCoverage( - gl, - defineRaycastSphereShader, - `emitRaycastSphere(vec3(0.0, 0.0, -1.0), ${radius});`, - ); - // The threshold is a silhouette sine of 0.25, which at one unit is a radius - // of 0.25. These two straddle it. - const belowThreshold = shadedAtRadius("0.24"); - const aboveThreshold = shadedAtRadius("0.26"); - expect(belowThreshold).toBeGreaterThan(0); - // Area follows the radius squared, so 0.26 over 0.24 predicts 1.174. - const ratio = aboveThreshold / belowThreshold; - expect(ratio).toBeGreaterThan(1.08); - expect(ratio).toBeLessThan(1.28); + // Centered 0.3 ahead with a radius of 0.5, so the sphere spans the eye plane. + const coverage = measureQuadCoverage( + gl, + defineRaycastSphereShader, + "emitRaycastSphere(vec3(0.0, 0.0, -0.3), 0.5);", + ); + expect(coverage).toBe(1); }); }); }); diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index ebc505933d..412e1ce7a9 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -26,17 +26,13 @@ * true sphere in raycast space. `uLightDirection` is read in the same space, so * the surface normal needs no further transform. * - * Two bounding strategies live here, and a primitive opts into the one it uses by - * calling `defineRaycastAabbQuad` or `defineRaycastAxialObbQuad`. Both bound the - * object in raycast space, project that bound to screen space, and emit a quad - * covering it. Neither is tied to one shape. - * `emitRaycastAabbQuad` takes a box, so it suits a roughly uniform object and it - * serves as the fallback wherever a tighter bound stops being finite. - * `emitRaycastAxialObbQuad` takes a segment and two radius vectors, so it suits an - * object with one long axis: a cone, a capsule, a cylinder. + * `emitRaycastAxialObbQuad`, which a primitive opts into by calling + * `defineRaycastAxialObbQuad`, bounds an object with one long axis: a cone, a + * capsule, a cylinder. It takes a segment and two radius vectors, bounds them in + * raycast space, projects that bound to screen space and emits a quad covering it. * - * A primitive whose own silhouette is cheap to bound exactly should do that in its - * own file instead. `raycast_sphere.ts` does. + * A primitive whose own silhouette has a closed form should bound itself in its own + * file instead, which is both tighter and less code. `raycast_sphere.ts` does. */ import { mat4 } from "#src/util/geom.js"; @@ -105,10 +101,10 @@ const glsl_raycastDepthRangeCull = ` highp vec2 raycastDepthPlaneDistances(highp vec4 clip) { return vec2(clip.z + clip.w, clip.w - clip.z); } -// Both distances are linear, so callers pass the maximum over the box corners. That -// is the larger base value plus the magnitude of each half-extent term. Negative -// form, so a non-finite value fails open and leaves the box drawn. -bool raycastBoxOutsideDepthRange(highp vec2 maxDepthDistances) { +// Both distances are linear, so callers pass the maximum over the shape: the base +// value plus how far the shape reaches along each distance's own gradient. Negative +// form, so a non-finite value fails open and leaves the shape drawn. +bool raycastOutsideDepthRange(highp vec2 maxDepthDistances) { return maxDepthDistances.x < 0.0 || maxDepthDistances.y < 0.0; } `; @@ -126,62 +122,6 @@ const highp float RAYCAST_MIN_RELATIVE_W = 1e-4; const highp float RAYCAST_MIN_AXIS_W_MARGIN = 1.25; `; -// Emits the screen-axis-aligned quad covering the raycast-space box -// `center +/- halfExtent`. -// -// Dropping a corner on or behind the near plane would leave a primitive that -// straddles the near plane undrawn. Its w is floored positive instead, which throws -// it far off-screen, and the NDC is clamped to keep the box finite. A clamped corner -// no longer bounds the silhouette, which is what the relative margin covers. -const glsl_raycastAabbQuad = ` -void emitRaycastAabbQuad(highp vec3 center, highp vec3 halfExtent) { - highp vec4 clipCenter = uProjection * vec4(center, 1.0); - highp vec4 clipX = uProjection[0] * halfExtent.x; - highp vec4 clipY = uProjection[1] * halfExtent.y; - highp vec4 clipZ = uProjection[2] * halfExtent.z; - - if (raycastBoxOutsideDepthRange( - raycastDepthPlaneDistances(clipCenter) - + abs(raycastDepthPlaneDistances(clipX)) - + abs(raycastDepthPlaneDistances(clipY)) - + abs(raycastDepthPlaneDistances(clipZ)))) { - gl_Position = vec4(2.0, 2.0, 2.0, 1.0); - return; - } - - // The largest |w| any corner can reach, and so the box's own w scale. Zero only - // for a zero-extent box on the eye plane, which draws nothing either way. - highp float maxAbsW = abs(clipCenter.w) - + abs(clipX.w) + abs(clipY.w) + abs(clipZ.w); - if (!(maxAbsW > 0.0)) { - gl_Position = vec4(2.0, 2.0, 2.0, 1.0); - return; - } - highp float minClipW = RAYCAST_MIN_RELATIVE_W * maxAbsW; - - highp vec2 ndcMin = vec2(RAYCAST_OFFSCREEN_NDC); - highp vec2 ndcMax = vec2(-RAYCAST_OFFSCREEN_NDC); - highp float ndcNearZ = 1.0; - - for (int corner = 0; corner < 8; ++corner) { - highp vec4 clip = clipCenter - + ((corner & 1) == 0 ? -clipX : clipX) - + ((corner & 2) == 0 ? -clipY : clipY) - + ((corner & 4) == 0 ? -clipZ : clipZ); - highp float clipW = max(clip.w, minClipW); - highp vec2 ndcXY = - clamp(clip.xy / clipW, vec2(-RAYCAST_OFFSCREEN_NDC), vec2(RAYCAST_OFFSCREEN_NDC)); - ndcMin = min(ndcMin, ndcXY); - ndcMax = max(ndcMax, ndcXY); - ndcNearZ = min(ndcNearZ, clamp(clip.z / clipW, -1.0, 1.0)); - } - - highp vec2 margin = (ndcMax - ndcMin) * 0.02 + 2.0 / uViewportSize; - highp vec2 quadCorner = getQuadVertexPosition(ndcMin - margin, ndcMax + margin); - gl_Position = vec4(quadCorner, ndcNearZ, 1.0); -} -`; - // A quad oriented along the projected axis, covering the OBB about the segment // endpointA..endpointB with radial half-extents radiusVectorA/B. // @@ -203,7 +143,7 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, highp vec4 clipVectorB = uProjection * vec4(radiusVectorB, 0.0); highp vec2 quadCoefficient = getQuadVertexPosition(vec2(-1.0), vec2(1.0)); - if (raycastBoxOutsideDepthRange( + if (raycastOutsideDepthRange( max(raycastDepthPlaneDistances(clipA), raycastDepthPlaneDistances(clipB)) + abs(raycastDepthPlaneDistances(clipVectorA)) + abs(raycastDepthPlaneDistances(clipVectorB)))) { @@ -329,11 +269,6 @@ export function raycastPrimitiveCoreModule(builder: ShaderBuilder) { builder.addFragmentCode(glsl_nearQuadraticRoot); } -export function defineRaycastAabbQuad(builder: ShaderBuilder) { - builder.require(raycastPrimitiveCoreModule); - builder.addVertexCode(glsl_raycastAabbQuad); -} - export function defineRaycastAxialObbQuad(builder: ShaderBuilder) { builder.require(raycastPrimitiveCoreModule); builder.addVertexCode(glsl_clipLineToDepthRange); diff --git a/src/webgl/raycast_sphere.ts b/src/webgl/raycast_sphere.ts index 6f8d9ce861..06616ed271 100644 --- a/src/webgl/raycast_sphere.ts +++ b/src/webgl/raycast_sphere.ts @@ -17,16 +17,96 @@ /** * @file Raycast sphere drawn on a camera-facing quad. The vertex stage bounds the * sphere with a quad and the fragment stage returns depth and a lighting factor. + * + * Both halves follow Inigo Quilez's sphere functions + * (https://iquilezles.org/articles/intersectors/ and + * https://iquilezles.org/articles/spherefunctions/), MIT licensed: + * + * The MIT License. Copyright (c) 2016 Inigo Quilez. + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: the above copyright + * notice and this permission notice shall be included in all copies or + * substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS". + * + * The bound is the sphere's own silhouette, which is exact and needs no margin. + * See `nearQuadraticRoot` in `raycast_shader_lib.ts` for how the intersection + * differs from the original. */ -import { defineRaycastPrimitiveCommon } from "#src/webgl/raycast_primitive.js"; +import { raycastPrimitiveCoreModule } from "#src/webgl/raycast_primitive.js"; import type { ShaderBuilder } from "#src/webgl/shader.js"; export function defineRaycastSphereShader(builder: ShaderBuilder) { - defineRaycastPrimitiveCommon(builder); + builder.require(raycastPrimitiveCoreModule); // xyz: center, w: radius. builder.addVarying("highp vec4", "vSphere", "flat"); builder.addVertexCode(` +// The quad covering the sphere's screen-space silhouette. +// +// That silhouette is a conic, and its clip-space form is the dual quadric +// M * Q * transpose(M), for M the x, y and w rows of uProjection and Q the dual of +// the sphere. Only five entries are needed, and each reduces to a product of two +// clip-space center components minus radiusSq times a dot product of two rows of +// uProjection. The extent along an axis is then the pair of roots of +// conicWW * t^2 - 2 * conicCross * t + conicDiagonal. +void emitRaycastSphereQuad(highp vec3 center, highp float radius) { + highp vec4 clipCenter = uProjection * vec4(center, 1.0); + highp float radiusSq = radius * radius; + highp vec3 rowX = vec3(uProjection[0].x, uProjection[1].x, uProjection[2].x); + highp vec3 rowY = vec3(uProjection[0].y, uProjection[1].y, uProjection[2].y); + highp vec3 rowZ = vec3(uProjection[0].z, uProjection[1].z, uProjection[2].z); + highp vec3 rowW = vec3(uProjection[0].w, uProjection[1].w, uProjection[2].w); + + // Each plane distance is largest at the center plus the radius along that + // distance's own gradient. + if (raycastOutsideDepthRange( + raycastDepthPlaneDistances(clipCenter) + + radius * vec2(length(rowZ + rowW), length(rowW - rowZ)))) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + + // Positive exactly when the sphere clears the eye plane, which is when the conic + // is an ellipse. Otherwise part of the sphere projects arbitrarily far and no + // quad short of the whole viewport covers it. Positive form, so a non-finite + // value takes the whole viewport rather than emitting a garbage quad. + highp float conicWW = clipCenter.w * clipCenter.w - radiusSq * dot(rowW, rowW); + if (!(conicWW > 0.0)) { + gl_Position = vec4(getQuadVertexPosition(vec2(-1.0), vec2(1.0)), 0.0, 1.0); + return; + } + + highp vec2 conicDiagonal = vec2( + clipCenter.x * clipCenter.x - radiusSq * dot(rowX, rowX), + clipCenter.y * clipCenter.y - radiusSq * dot(rowY, rowY)); + highp vec2 conicCross = vec2( + clipCenter.x * clipCenter.w - radiusSq * dot(rowX, rowW), + clipCenter.y * clipCenter.w - radiusSq * dot(rowY, rowW)); + // The max guards rounding only. A real ellipse cannot give a negative value. + highp vec2 halfExtent = + sqrt(max(conicCross * conicCross - conicDiagonal * conicWW, vec2(0.0))) + / conicWW; + highp vec2 centerNdc = conicCross / conicWW; + highp vec2 ndcMin = centerNdc - halfExtent; + highp vec2 ndcMax = centerNdc + halfExtent; + if (any(greaterThan(ndcMin, vec2(RAYCAST_OFFSCREEN_NDC))) || + any(lessThan(ndcMax, vec2(-RAYCAST_OFFSCREEN_NDC)))) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + + // The fragment shader writes gl_FragDepth and discards a depth outside the + // range, so the quad's own depth only has to survive clipping. Zero always does. + gl_Position = vec4( + clamp(getQuadVertexPosition(ndcMin, ndcMax), + vec2(-RAYCAST_OFFSCREEN_NDC), vec2(RAYCAST_OFFSCREEN_NDC)), + 0.0, 1.0); +} + void emitRaycastSphere(highp vec3 center, highp float radius) { // No radius, no surface to hit. A node behind the eye reaches this every frame. // Positive form, so a non-finite radius culls too. @@ -35,17 +115,35 @@ void emitRaycastSphere(highp vec3 center, highp float radius) { return; } vSphere = vec4(center, radius); - emitRaycastAabbQuad(center, vec3(radius)); + emitRaycastSphereQuad(center, radius); } `); builder.addFragmentCode(` RaycastHit intersectRaycastPrimitive() { RaycastRay ray = getRaycastRayThroughFragment(); - RaycastCircleHit circleHit = intersectRaycastCircle( - ray.origin - vSphere.xyz, ray.direction, vSphere.w); - if (!circleHit.hit) return raycastMiss(); - return makeRaycastHit(ray.origin + circleHit.distAlongRay * ray.direction, - circleHit.normal); + highp float radius = vSphere.w; + + // Split along the ray direction, so the parameter below is measured from the + // closest approach to the center. The direction is unit, so the leading + // coefficient is one and the linear term vanishes. + VectorSplit originSplit = + splitAlongDirection(ray.origin - vSphere.xyz, ray.direction); + highp float perpendicularDistSq = + dot(originSplit.perpendicular, originSplit.perpendicular); + QuadraticNearRoot root = + nearQuadraticRoot(1.0, 0.0, perpendicularDistSq - radius * radius); + if (!root.exists) return raycastMiss(); + + // The near crossing. Taking the far one would fill the view when the camera + // clips inside the geometry. + highp float hitDist = -originSplit.parallelDist + root.value; + if (!(hitDist >= 0.0)) return raycastMiss(); + + // Hit point minus center, formed from two small terms rather than by subtracting + // the center from a hit point that can be far from the origin. + return makeRaycastHit( + ray.origin + hitDist * ray.direction, + originSplit.perpendicular + root.value * ray.direction); } `); } From 2650bedcf216d23bc83c3c27c6db09cbc760fa21 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 31 Aug 2026 22:24:04 +0200 Subject: [PATCH 25/33] fix: correct line attribution for user shader --- src/annotation/type_handler.ts | 8 ++--- src/volume_rendering/volume_render_layer.ts | 6 ++-- src/webgl/dynamic_shader.browser_test.ts | 39 +++++++++++++++++++++ src/webgl/dynamic_shader.ts | 19 ++++++++++ src/webgl/lines.ts | 9 +++++ 5 files changed, 71 insertions(+), 10 deletions(-) create mode 100644 src/webgl/dynamic_shader.browser_test.ts diff --git a/src/annotation/type_handler.ts b/src/annotation/type_handler.ts index c8fae8d7c4..a23d1efa75 100644 --- a/src/annotation/type_handler.ts +++ b/src/annotation/type_handler.ts @@ -40,7 +40,7 @@ import type { } from "#src/webgl/dynamic_shader.js"; import { parameterizedEmitterDependentShaderGetter, - shaderCodeWithLineDirective, + wrapUserShaderMain, } from "#src/webgl/dynamic_shader.js"; import { defineInvlerpShaderFunction, @@ -560,11 +560,7 @@ void userMain(); } defineShader(builder); builder.addVertexCode(glsl_string); - builder.addVertexCode( - "\n#define main userMain\n" + - shaderCodeWithLineDirective(parameters.parseResult.code) + - "\n#undef main\n", - ); + builder.addVertexCode(wrapUserShaderMain(parameters.parseResult.code)); }, }); } diff --git a/src/volume_rendering/volume_render_layer.ts b/src/volume_rendering/volume_render_layer.ts index d28eb1d5e5..b2d7d751c6 100644 --- a/src/volume_rendering/volume_render_layer.ts +++ b/src/volume_rendering/volume_render_layer.ts @@ -89,7 +89,7 @@ import type { import { parameterizedContextDependentShaderGetter, parameterizedEmitterDependentShaderGetter, - shaderCodeWithLineDirective, + wrapUserShaderMain, } from "#src/webgl/dynamic_shader.js"; import type { HistogramChannelSpecification, @@ -521,9 +521,7 @@ void main() { addControlsToBuilder(shaderBuilderState, builder); builder.addFragmentCode(glsl_string); builder.addFragmentCode( - "\n#define main userMain\n" + - shaderCodeWithLineDirective(shaderBuilderState.parseResult.code) + - "\n#undef main\n", + wrapUserShaderMain(shaderBuilderState.parseResult.code), ); }, }, diff --git a/src/webgl/dynamic_shader.browser_test.ts b/src/webgl/dynamic_shader.browser_test.ts new file mode 100644 index 0000000000..5343750d64 --- /dev/null +++ b/src/webgl/dynamic_shader.browser_test.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from "vitest"; +import { wrapUserShaderMain } from "#src/webgl/dynamic_shader.js"; + +describe("wrapUserShaderMain", () => { + // The whole output is the contract. Three call sites append generated code after + // it, and the trailing reset is what keeps a compile error in that code from + // being reported against the user's shader. + it("renames main and hands line attribution back", () => { + expect(wrapUserShaderMain("void main() {\n emitDefault();\n}")).toBe( + "\n#define main userMain\n" + + "\n#line 0 1\n" + + "void main() {\n emitDefault();\n}" + + "\n#undef main\n#line 1 0\n", + ); + }); + + it("ends outside the user's source string", () => { + const wrapped = wrapUserShaderMain("void main() {}"); + const directives = wrapped.match(/#line \d+ \d+/g); + expect(directives).toEqual(["#line 0 1", "#line 1 0"]); + expect(wrapped.endsWith("#line 1 0\n")).toBe(true); + }); +}); diff --git a/src/webgl/dynamic_shader.ts b/src/webgl/dynamic_shader.ts index e385a5220e..4436327a2b 100644 --- a/src/webgl/dynamic_shader.ts +++ b/src/webgl/dynamic_shader.ts @@ -276,3 +276,22 @@ export function shaderCodeWithLineDirective( ) { return `\n#line ${line} ${sourceStringNumber}\n` + code; } + +/** + * Renames the `main` in user-supplied shader code to `userMain`, so that generated + * code can call it where it chooses. + * + * The `#line` directive that attributes the user's code to source string 1 stays in + * effect for everything the builder appends after it, because the whole shader is + * one source string. Without the reset at the end, a compile error in generated + * code is reported against the user's source, and the shader control widget marks + * a line of their code that has nothing wrong with it. Only the source string index + * matters here; the line number after the reset is not meaningful. + */ +export function wrapUserShaderMain(code: string) { + return ( + "\n#define main userMain\n" + + shaderCodeWithLineDirective(code) + + "\n#undef main\n#line 1 0\n" + ); +} diff --git a/src/webgl/lines.ts b/src/webgl/lines.ts index cba11e5dc1..91c96f5f38 100644 --- a/src/webgl/lines.ts +++ b/src/webgl/lines.ts @@ -38,6 +38,15 @@ export function defineLineShader( rounded = false, endpointClipping = false, ) { + // A rounded line takes its alpha from getRoundedLineColor and never calls + // getLineAlpha, where the clip lives. Supporting both would mean a second + // discard site that no caller exercises, so reject the pair instead of + // ignoring the clip radius without a word. + if (rounded && endpointClipping) { + throw new Error( + "defineLineShader does not support endpoint clipping on rounded lines.", + ); + } builder.addVertexCode(glsl_getQuadVertexPosition); // x: 1 / viewportWidth // y: 1 / viewportHeight From ef45cc17bfa754bf5c321e90d2854c1221581802 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 31 Aug 2026 22:24:14 +0200 Subject: [PATCH 26/33] refactor: namings --- src/webgl/raycast_primitive.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index 412e1ce7a9..1633e5d455 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -182,10 +182,10 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, highp float axisLengthPixels = length(axisPixels); highp vec2 alongDirection = axisLengthPixels > 1e-3 ? axisPixels / axisLengthPixels : vec2(1.0, 0.0); - highp vec2 perpDirection = vec2(-alongDirection.y, alongDirection.x); + highp vec2 perpendicularDirection = vec2(-alongDirection.y, alongDirection.x); highp vec2 pixelCenter = (pixelsA + pixelsB) * 0.5; highp float halfAlongPixels = 0.0; - highp float halfPerpPixels = 0.0; + highp float halfPerpendicularPixels = 0.0; highp float ndcNearZ = 1.0; for (int corner = 0; corner < 8; ++corner) { @@ -194,14 +194,14 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, + ((corner & 4) == 0 ? -clipVectorB : clipVectorB); highp vec2 offset = raycastClipToPixels(clip) - pixelCenter; halfAlongPixels = max(halfAlongPixels, abs(dot(offset, alongDirection))); - halfPerpPixels = max(halfPerpPixels, abs(dot(offset, perpDirection))); + halfPerpendicularPixels = max(halfPerpendicularPixels, abs(dot(offset, perpendicularDirection))); ndcNearZ = min(ndcNearZ, clamp(clip.z / clip.w, -1.0, 1.0)); } // The corner bound is exact. One pixel covers numerical error. highp vec2 pixels = pixelCenter + alongDirection * (quadCoefficient.x * (halfAlongPixels + 1.0)) - + perpDirection * (quadCoefficient.y * (halfPerpPixels + 1.0)); + + perpendicularDirection * (quadCoefficient.y * (halfPerpendicularPixels + 1.0)); gl_Position = vec4(pixels * 2.0 / uViewportSize, ndcNearZ, 1.0); } `; From f3153f58b38c9e5838d4a899478c617d42779647 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 31 Aug 2026 22:24:36 +0200 Subject: [PATCH 27/33] perf: reduce CPU work on skeleton draw passes --- src/skeleton/frontend.ts | 76 +++++++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index e7ce588197..ddd93a3913 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -68,7 +68,7 @@ import type { WatchableShaderError } from "#src/webgl/dynamic_shader.js"; import { makeTrackableFragmentMain, parameterizedEmitterDependentShaderGetter, - shaderCodeWithLineDirective, + wrapUserShaderMain, } from "#src/webgl/dynamic_shader.js"; import { defineLineShader, @@ -146,6 +146,14 @@ function hasEnlargedNodes(mode: SkeletonRenderMode) { ); } +// One skeleton that is ready to draw, with what both passes need to draw it. The +// records are pooled across frames, so a steady view allocates nothing. +interface VisibleSkeletonToDraw { + skeleton: SkeletonChunk; + pickIndex: number; + readonly color: Float32Array; +} + interface VertexAttributeRenderInfo extends VertexAttributeInfo { name: string; webglDataType: number; @@ -404,9 +412,7 @@ void emitDefault() { // Run our main before user main to discard early builder.addFragmentCode("void userMain();\n"); builder.addFragmentCode( - "\n#define main userMain\n" + - shaderCodeWithLineDirective(shaderBuilderState.parseResult.code) + - "\n#undef main\n", + wrapUserShaderMain(shaderBuilderState.parseResult.code), ); builder.setFragmentMain( (useRaycast ? glsl_raycastFragmentSetup : "") + "userMain();", @@ -738,6 +744,8 @@ export class SkeletonLayer extends RefCounted { fallbackShaderParameters = new WatchableValue( getFallbackBuilderState(parseShaderUiControls(DEFAULT_FRAGMENT_MAIN)), ); + private readonly visibleSkeletons: VisibleSkeletonToDraw[] = []; + private visibleSkeletonCount = 0; get visibility() { return this.sharedObject.visibility; @@ -835,6 +843,8 @@ export class SkeletonLayer extends RefCounted { const { shaderControlState } = this.displayState.skeletonRenderingOptions; const { projectionParameters } = renderContext; + this.collectVisibleSkeletons(layer, renderContext); + edgeShader.bind(); renderHelper.beginLayer(gl, edgeShader, renderContext, modelMatrix); setControlsInShader( @@ -851,7 +861,7 @@ export class SkeletonLayer extends RefCounted { nodeDiameter, ); const aVertexIndex = renderHelper.beginEdges(edgeShader); - this.drawPass(layer, renderContext, renderHelper, edgeShader, (skeleton) => + this.drawPass(renderContext, renderHelper, edgeShader, (skeleton) => renderHelper.drawEdges(gl, edgeShader, aVertexIndex, skeleton), ); renderHelper.endEdges(aVertexIndex); @@ -870,46 +880,72 @@ export class SkeletonLayer extends RefCounted { shaderControlState, nodeShaderParameters.parseResult, ); - this.drawPass(layer, renderContext, renderHelper, nodeShader, (skeleton) => + this.drawPass(renderContext, renderHelper, nodeShader, (skeleton) => renderHelper.drawNodes(gl, nodeShader, skeleton), ); renderHelper.endLayer(gl, edgeShader, nodeShader); } - // Each pass registers pick IDs again, so a segment ends up with one ID for its - // edges and another for its nodes. Both map to that segment, so picking is - // unaffected. - private drawPass( + // Walks the visible segments once for both passes. Doing it per pass would run + // the walk, the color lookup and the pick ID registration twice per segment, and + // would give a segment two pick IDs. + private collectVisibleSkeletons( layer: RenderLayer, renderContext: SliceViewPanelRenderContext | PerspectiveViewRenderContext, - renderHelper: RenderHelper, - shader: ShaderProgram, - drawChunk: (skeleton: SkeletonChunk) => void, ) { - const { gl, displayState } = this; + const { displayState, visibleSkeletons } = this; const skeletons = this.source.chunks; + let count = 0; forEachVisibleSegmentToDraw( displayState, layer, renderContext.emitColor, renderContext.emitPickID ? renderContext.pickIDs : undefined, (objectId, color, pickIndex) => { - const key = getObjectKey(objectId); - const skeleton = skeletons.get(key); + const skeleton = skeletons.get(getObjectKey(objectId)); if ( skeleton === undefined || skeleton.state !== ChunkState.GPU_MEMORY ) { return; } - if (color !== undefined) renderHelper.setColor(gl, shader, color); - if (pickIndex !== undefined) { - renderHelper.setPickID(gl, shader, pickIndex); + let entry = visibleSkeletons[count]; + if (entry === undefined) { + entry = visibleSkeletons[count] = { + skeleton, + pickIndex: 0, + color: new Float32Array(4), + }; } - drawChunk(skeleton); + entry.skeleton = skeleton; + entry.pickIndex = pickIndex ?? 0; + // getObjectColor hands back a shared temporary, so this has to be a copy. + if (color !== undefined) entry.color.set(color); + ++count; }, ); + // A no-op while the count holds steady. On a drop it releases the records, so + // the pool cannot keep a skeleton alive after it leaves the view. + visibleSkeletons.length = count; + this.visibleSkeletonCount = count; + } + + private drawPass( + renderContext: SliceViewPanelRenderContext | PerspectiveViewRenderContext, + renderHelper: RenderHelper, + shader: ShaderProgram, + drawChunk: (skeleton: SkeletonChunk) => void, + ) { + const { gl, visibleSkeletons, visibleSkeletonCount } = this; + // Both are decided per frame, not per segment. + const { emitColor, emitPickID } = renderContext; + for (let i = 0; i < visibleSkeletonCount; ++i) { + const entry = visibleSkeletons[i]; + if (emitColor) renderHelper.setColor(gl, shader, entry.color); + if (emitPickID) renderHelper.setPickID(gl, shader, entry.pickIndex); + drawChunk(entry.skeleton); + } } isReady() { From 02a10d6e6371b1c6c7a5b1f0bf7112f330ccd4f3 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 31 Aug 2026 23:01:55 +0200 Subject: [PATCH 28/33] refactor: update comments --- src/skeleton/frontend.ts | 30 +++++------ src/webgl/dynamic_shader.browser_test.ts | 5 +- src/webgl/dynamic_shader.ts | 11 ++-- src/webgl/lines.browser_test.ts | 10 ++-- src/webgl/lines.ts | 12 ++--- src/webgl/raycast_primitive.browser_test.ts | 59 +++++++++------------ src/webgl/raycast_primitive.ts | 31 ++++------- src/webgl/raycast_shader_lib.ts | 16 +++--- src/webgl/raycast_sphere.ts | 40 ++++++-------- src/webgl/raycast_truncated_cone.ts | 50 ++++++++--------- 10 files changed, 106 insertions(+), 158 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index ddd93a3913..fb397ac427 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -146,8 +146,7 @@ function hasEnlargedNodes(mode: SkeletonRenderMode) { ); } -// One skeleton that is ready to draw, with what both passes need to draw it. The -// records are pooled across frames, so a steady view allocates nothing. +// Pooled across frames, so a steady view allocates nothing. interface VisibleSkeletonToDraw { skeleton: SkeletonChunk; pickIndex: number; @@ -363,10 +362,8 @@ void emitDefault() { ); } - // `edgeMixExpression` is set only where one draw covers a whole edge, as the - // cone does. A vertex attribute has a value at each end, and the expression - // gives where the fragment falls between them. Without it every fragment of an - // edge would read the same end. + // `edgeMixExpression` is set only where one draw covers a whole edge, as the cone + // does, and gives where the fragment falls between the two ends. private finalizeShaderBuilder( builder: ShaderBuilder, shaderBuilderState: ShaderControlsBuilderState, @@ -409,11 +406,11 @@ void emitDefault() { builder.setVertexMain(vertexMain); addControlsToBuilder(shaderBuilderState, builder); builder.addFragmentCode(glsl_string); - // Run our main before user main to discard early builder.addFragmentCode("void userMain();\n"); builder.addFragmentCode( wrapUserShaderMain(shaderBuilderState.parseResult.code), ); + // The raycast setup runs first so that a miss discards before the user's code. builder.setFragmentMain( (useRaycast ? glsl_raycastFragmentSetup : "") + "userMain();", ); @@ -476,12 +473,10 @@ void emitDefault() { this.vertexIdHelper.enable(); } - // The raycast solves a true sphere in display space, which is the global space - // scaled to canonical voxels. Display space reaches the eye through a rotation - // and a uniform scale, so a node is round on screen only when it is round there. - // In layer space an anisotropic dataset would draw every node as an ellipsoid. - // The light direction is given in display space, so the surface normal that the - // raycast returns needs no further transform. + // The raycast solves a true sphere in display space, the global space scaled to + // canonical voxels. Solving in layer space would draw every node of an + // anisotropic dataset as an ellipsoid. The light direction is given in the same + // space, so the surface normal needs no further transform. private setRaycastUniforms( gl: GL, shader: ShaderProgram, @@ -887,9 +882,9 @@ export class SkeletonLayer extends RefCounted { renderHelper.endLayer(gl, edgeShader, nodeShader); } - // Walks the visible segments once for both passes. Doing it per pass would run - // the walk, the color lookup and the pick ID registration twice per segment, and - // would give a segment two pick IDs. + // Once for both passes. Per pass would run the walk, the color lookup and the + // pick ID registration twice per segment, and give a segment two pick IDs. + private collectVisibleSkeletons( layer: RenderLayer, renderContext: SliceViewPanelRenderContext | PerspectiveViewRenderContext, @@ -926,7 +921,7 @@ export class SkeletonLayer extends RefCounted { }, ); // A no-op while the count holds steady. On a drop it releases the records, so - // the pool cannot keep a skeleton alive after it leaves the view. + // the pool cannot hold a skeleton alive after it leaves the view. visibleSkeletons.length = count; this.visibleSkeletonCount = count; } @@ -938,7 +933,6 @@ export class SkeletonLayer extends RefCounted { drawChunk: (skeleton: SkeletonChunk) => void, ) { const { gl, visibleSkeletons, visibleSkeletonCount } = this; - // Both are decided per frame, not per segment. const { emitColor, emitPickID } = renderContext; for (let i = 0; i < visibleSkeletonCount; ++i) { const entry = visibleSkeletons[i]; diff --git a/src/webgl/dynamic_shader.browser_test.ts b/src/webgl/dynamic_shader.browser_test.ts index 5343750d64..abc2743902 100644 --- a/src/webgl/dynamic_shader.browser_test.ts +++ b/src/webgl/dynamic_shader.browser_test.ts @@ -18,9 +18,8 @@ import { describe, expect, it } from "vitest"; import { wrapUserShaderMain } from "#src/webgl/dynamic_shader.js"; describe("wrapUserShaderMain", () => { - // The whole output is the contract. Three call sites append generated code after - // it, and the trailing reset is what keeps a compile error in that code from - // being reported against the user's shader. + // The whole output is the contract, the trailing reset most of all: three call + // sites append generated code after it. it("renames main and hands line attribution back", () => { expect(wrapUserShaderMain("void main() {\n emitDefault();\n}")).toBe( "\n#define main userMain\n" + diff --git a/src/webgl/dynamic_shader.ts b/src/webgl/dynamic_shader.ts index 4436327a2b..ec6c6a77f5 100644 --- a/src/webgl/dynamic_shader.ts +++ b/src/webgl/dynamic_shader.ts @@ -281,12 +281,11 @@ export function shaderCodeWithLineDirective( * Renames the `main` in user-supplied shader code to `userMain`, so that generated * code can call it where it chooses. * - * The `#line` directive that attributes the user's code to source string 1 stays in - * effect for everything the builder appends after it, because the whole shader is - * one source string. Without the reset at the end, a compile error in generated - * code is reported against the user's source, and the shader control widget marks - * a line of their code that has nothing wrong with it. Only the source string index - * matters here; the line number after the reset is not meaningful. + * The whole shader is one source string, so the `#line` directive attributing the + * user's code to source string 1 stays in effect for whatever the builder appends + * next. Without the reset at the end, a compile error in that generated code is + * reported against the user's source, and the shader control widget marks a line + * of theirs that has nothing wrong with it. */ export function wrapUserShaderMain(code: string) { return ( diff --git a/src/webgl/lines.browser_test.ts b/src/webgl/lines.browser_test.ts index e0dd045a87..4dfe51c24e 100644 --- a/src/webgl/lines.browser_test.ts +++ b/src/webgl/lines.browser_test.ts @@ -29,9 +29,8 @@ const VIEWPORT_SIZE = 64; const LINE_WIDTH_IN_PIXELS = 6; const CLIP_RADIUS_IN_PIXELS = 10; -// One line, drawn with endpoint clipping, read back as the set of covered pixels. -// `endpointsClip` gives both endpoints in clip space, so a test can put an -// endpoint outside the depth range without setting up a projection. +// `endpointsClip` gives both endpoints in clip space, so a test can put an endpoint +// outside the depth range without setting up a projection. function drawClippedLine( gl: GL, endpointsClip: string, @@ -109,9 +108,8 @@ describe("line endpoint clipping", () => { }); }); - // Depth clipping moves the drawn ends inward. Measuring the clip disc from - // those moved ends would eat the drawn line at a point where no node exists, - // because the node itself was clipped away with the rest of the segment. + // Measuring the disc from the depth-clipped ends would eat the drawn line where + // no node exists, the node itself having been clipped away with the segment. it("measures from the given endpoints, not the depth-clipped ones", () => { webglTest((gl) => { // z runs from -3 to 3, so only the middle third survives the depth range. diff --git a/src/webgl/lines.ts b/src/webgl/lines.ts index 91c96f5f38..3c4abb3ba0 100644 --- a/src/webgl/lines.ts +++ b/src/webgl/lines.ts @@ -38,10 +38,8 @@ export function defineLineShader( rounded = false, endpointClipping = false, ) { - // A rounded line takes its alpha from getRoundedLineColor and never calls - // getLineAlpha, where the clip lives. Supporting both would mean a second - // discard site that no caller exercises, so reject the pair instead of - // ignoring the clip radius without a word. + // The clip lives in getLineAlpha, which a rounded line never calls. Rejecting + // the pair beats ignoring the clip radius without a word. if (rounded && endpointClipping) { throw new Error( "defineLineShader does not support endpoint clipping on rounded lines.", @@ -56,9 +54,9 @@ export function defineLineShader( // max(1e-6, featherWidth) / (lineWidth + featherWidth) builder.addVarying("highp float", "vLineFeatherFraction"); if (endpointClipping) { - // Window coordinates, matching gl_FragCoord.xy, of the endpoints as given. - // Depth clipping moves the drawn ends, so these are taken before it. - // xy: endpoint A, zw: endpoint B. + // xy: endpoint A, zw: endpoint B, in the window coordinates gl_FragCoord uses. + // Taken before depth clipping, which moves the drawn ends away from them. + builder.addVarying("highp vec4", "vLineEndpointsWindow", "flat"); builder.addVarying("highp float", "vLineEndpointClipRadius", "flat"); } diff --git a/src/webgl/raycast_primitive.browser_test.ts b/src/webgl/raycast_primitive.browser_test.ts index 5b821be07d..6b8f44a442 100644 --- a/src/webgl/raycast_primitive.browser_test.ts +++ b/src/webgl/raycast_primitive.browser_test.ts @@ -107,10 +107,9 @@ function renderPrimitive( } } -// Fraction of the viewport that the bounding quad rasterises. The fragment -// shader writes unconditionally, so this measures the vertex stage: an -// out-of-range quad is counted here but discarded by the real shader, making it -// invisible to any test of the shaded result. +// The fragment shader writes unconditionally, so this measures the vertex stage +// alone. An out-of-range quad is counted here but discarded by the real shader, +// which makes it invisible to any test of the shaded result. function measureQuadCoverage( gl: GL, definePrimitive: (builder: ShaderBuilder) => void, @@ -130,9 +129,8 @@ function measureQuadCoverage( return covered / (size * size); } -// Fraction of the viewport the primitive's own surface shades, with the real -// fragment setup so a miss discards. Unlike quad coverage this measures the -// surface, so it falls if a bounding quad clips the primitive. +// Runs the real fragment setup, so a miss discards. Unlike quad coverage this +// measures the surface, so it falls if a bounding quad clips the primitive. function measureShadedCoverage( gl: GL, definePrimitive: (builder: ShaderBuilder) => void, @@ -238,9 +236,8 @@ describe("raycast primitives", () => { }); }); - // An upright cone one unit in front of the camera, shaded with the axial - // fraction. Endpoint A is the lower end, and readPixels returns rows bottom up, - // so the result runs from endpoint A to endpoint B. Values are 0 to 255. + // Endpoint A is the lower end and readPixels returns rows bottom up, so the + // result runs from endpoint A to endpoint B. Values are 0 to 255. function renderUprightCone( gl: GL, radiusA: string, @@ -300,9 +297,8 @@ describe("raycast primitives", () => { return widthByRow; } - // A skeleton edge carries a vertex attribute at each end, and the consumer mixes - // the two by this fraction. A constant value would colour a whole edge from one - // endpoint, so the test checks that it runs the length of the cone. + // A consumer mixes an attribute's two end values by this fraction. A constant + // would colour a whole edge from one endpoint. it("reports where a cone hit falls between the endpoints", () => { webglTest((gl) => { const fractionByRow = shadedConeAxialFractionByRow(gl, 0, 0); @@ -317,8 +313,8 @@ describe("raycast primitives", () => { }); // Equal end radii must leave the taper rate at zero, so the quadratic collapses - // to the fixed-radius circle test. A cylinder is the common case, and any drift - // here would show as a width that changes along a cone that should not taper. + // to the fixed-radius circle test. Drift would show as a width that changes along + // a cone that should not taper. it("draws an exact cylinder when both end radii match", () => { webglTest((gl) => { const widthByRow = coneWidthByRow( @@ -334,10 +330,8 @@ describe("raycast primitives", () => { }); }); - // The taper is what holds one on-screen width along a receding edge. Endpoint A - // is the lower end here, so the drawn width has to grow from bottom to top. - // - // The rows nearest each end are left out. The ends are open, so the rim there + // Endpoint A is the lower end here, so the drawn width has to grow from bottom to + // top. The rows nearest each end are left out: the ends are open, so the rim // projects as an ellipse and the silhouette closes over the last few rows. it("tapers between two different end radii", () => { webglTest((gl) => { @@ -357,9 +351,8 @@ describe("raycast primitives", () => { }); }); - // Both ends at the same depth ask for the same radius, and the requested pixel - // radius has to come back as the drawn width. This checks the whole chain from a - // pixel radius through the per-end radii to the rasterised silhouette. + // The whole chain, from a pixel radius through the per-end radii to the + // rasterised silhouette. it("draws a segment at the requested pixel radius", () => { webglTest((gl) => { const endpoints = "vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0)"; @@ -367,7 +360,6 @@ describe("raycast primitives", () => { const widthByRow = coneWidthByRow(gl, `${radii}.x`, `${radii}.y`); expect(widthByRow.length).toBeGreaterThan(8); // A radius of 6 device pixels is a 12 pixel width, plus or minus a pixel. - // The test above already covers the width holding along the cone. expect(Math.max(...widthByRow)).toBeGreaterThan(10); expect(Math.max(...widthByRow)).toBeLessThan(14); }); @@ -375,7 +367,8 @@ describe("raycast primitives", () => { // The clip radius hands the region around a joint to the ball drawn there. The // surface sits one radius from the axis, so a clip radius of 0.15 reaches - // sqrt(0.15^2 - 0.05^2) = 0.1414 along a 0.6 long axis: the lowest 23.6 percent. + // sqrt(0.15^2 - 0.05^2) = 0.1414 along a 0.6 long axis. That is the lowest 23.6 + // percent of it. it("clips the cone surface around an endpoint", () => { webglTest((gl) => { const clipped = shadedConeAxialFractionByRow(gl, 0.15, 0); @@ -390,10 +383,9 @@ describe("raycast primitives", () => { }); }); - // A radius of zero has no surface for the fragment shader to hit, and reaching - // the quad emitters with one leaves the radius vectors degenerate. Both radius - // helpers return zero for a point at or behind the eye, so this runs every frame - // on any skeleton with geometry behind the camera. + // A radius of zero has no surface to hit, so shading its quad is pure waste. Both + // radius helpers return zero for a point at or behind the eye, so this runs every + // frame on any skeleton with geometry behind the camera. it("culls a zero-radius primitive", () => { webglTest((gl) => { expect( @@ -428,11 +420,10 @@ describe("raycast primitives", () => { }); }); - // A tighter bound only pays if it still contains the whole surface. The exact - // silhouette of a sphere of radius r at distance d has radius r / sqrt(d^2 - r^2), - // which for r of 0.2 at one unit is 4 percent more area than the r / d disc. The - // conic gives that exactly, so the shaded surface has to exceed the plain disc - // rather than fall short of it, which is what a quad clipping the sphere would do. + // The silhouette of a sphere of radius r at distance d has radius + // r / sqrt(d^2 - r^2), which for r of 0.2 at one unit is 4 percent more area than + // the r / d disc. So the shaded surface has to exceed that disc. Falling short of + // it is what a quad clipping the sphere would produce. it("bounds a sphere without clipping its surface", () => { webglTest((gl) => { const shaded = measureShadedCoverage( @@ -449,7 +440,7 @@ describe("raycast primitives", () => { // The conic is an ellipse only while the sphere clears the eye plane. Past that, // part of the sphere projects arbitrarily far, so the whole viewport is the only - // honest bound, and nothing may be lost by taking it. + // honest bound. it("takes the whole viewport when the sphere crosses the eye plane", () => { webglTest((gl) => { // Centered 0.3 ahead with a radius of 0.5, so the sphere spans the eye plane. diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index 1633e5d455..5b677f79ae 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -26,11 +26,7 @@ * true sphere in raycast space. `uLightDirection` is read in the same space, so * the surface normal needs no further transform. * - * `emitRaycastAxialObbQuad`, which a primitive opts into by calling - * `defineRaycastAxialObbQuad`, bounds an object with one long axis: a cone, a - * capsule, a cylinder. It takes a segment and two radius vectors, bounds them in - * raycast space, projects that bound to screen space and emits a quad covering it. - * + * The bound here suits an object with one long axis: a cone, a capsule, a cylinder. * A primitive whose own silhouette has a closed form should bound itself in its own * file instead, which is both tighter and less code. `raycast_sphere.ts` does. */ @@ -94,9 +90,8 @@ RaycastHit makeRaycastHit(highp vec3 surfacePoint, highp vec3 normal) { } `; -// The emitters below over-cover so that a primitive straddling the near plane is -// never lost. That also drags an out-of-range primitive back on screen, past a -// fixed-function clipper that can no longer see where it really is. +// The emitter below over-covers, so that a primitive straddling the near plane is +// never lost. const glsl_raycastDepthRangeCull = ` highp vec2 raycastDepthPlaneDistances(highp vec4 clip) { return vec2(clip.z + clip.w, clip.w - clip.z); @@ -115,6 +110,7 @@ const glsl_raycastQuadConstants = ` const highp float RAYCAST_OFFSCREEN_NDC = 2.0; // Smallest clip w a projected point may be treated as having, as a fraction of the // local w scale. Relative, so it holds whatever units the projection works in. + const highp float RAYCAST_MIN_RELATIVE_W = 1e-4; // Nearest clip w an axis may keep, as a multiple of the depth that the radial // half-extents span. The margin over 1.0 is what a corner keeps in front of the @@ -122,9 +118,6 @@ const highp float RAYCAST_MIN_RELATIVE_W = 1e-4; const highp float RAYCAST_MIN_AXIS_W_MARGIN = 1.25; `; -// A quad oriented along the projected axis, covering the OBB about the segment -// endpointA..endpointB with radial half-extents radiusVectorA/B. -// // Depth-clipping the segment first is what makes an oriented quad possible. A // primitive crossing the eye plane has an unbounded footprint, and clipping leaves // every corner in front of the eye where the projected-corner hull is a valid bound. @@ -222,15 +215,12 @@ highp float raycastRadiusFromClipW(highp float clipW, highp float radiusInPixels highp float getRaycastRadiusForPixels(highp vec3 point, highp float radiusInPixels) { return raycastRadiusFromClipW((uProjection * vec4(point, 1.0)).w, radiusInPixels); } -// A radius for each end of a segment, so that the segment holds one on-screen -// width along its whole length. A single radius cannot: the far end of a receding -// segment would draw thinner than the near end, and thinner than a node drawn -// there at the same pixel radius. +// x at endpointA, y at endpointB. Two radii rather than one, so that the segment +// holds a single on-screen width along its whole length. A single radius would +// draw the far end of a receding segment thinner than the near end. // -// x is the radius at endpointA and y the radius at endpointB. An endpoint at or -// behind the eye has no on-screen size, so it borrows the other end's radius and -// the segment draws without taper. Both behind the eye leaves both zero, which -// the emitter culls. +// An endpoint at or behind the eye has no on-screen size, so it borrows the other +// end's radius. Both behind the eye leaves both zero, which the emitter culls. highp vec2 getRaycastSegmentRadiiForPixels( highp vec3 endpointA, highp vec3 endpointB, highp float radiusInPixels) { highp vec2 radii = vec2( @@ -253,8 +243,7 @@ raycastSurfaceDepth = raycastHit.windowDepth; raycastLightingFactor = raycastHit.lightingFactor; `; -// Everything a raycast primitive needs whatever shape it draws, and whatever bound -// it uses. A ShaderModule, so requiring it twice adds its code once. +// A ShaderModule, so requiring it twice adds its code once. export function raycastPrimitiveCoreModule(builder: ShaderBuilder) { builder.require(projectionMatrixShaderModule); builder.addUniform("highp mat4", "uInvProjection"); diff --git a/src/webgl/raycast_shader_lib.ts b/src/webgl/raycast_shader_lib.ts index 0d4a843902..7f6b638414 100644 --- a/src/webgl/raycast_shader_lib.ts +++ b/src/webgl/raycast_shader_lib.ts @@ -32,10 +32,9 @@ * substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS". * * One change. A caller measures its parameter from the ray's closest approach to - * the surface's axis and subtracts the perpendicular distance from the radius, - * rather than forming the original `c = dot(oc, oc) - r * r`. Neuroglancer models - * can sit far from the origin, and the rearranged form never subtracts two large - * numbers. + * the axis, rather than forming the original `c = dot(oc, oc) - r * r`. + * Neuroglancer models can sit far from the origin, and the rearranged form never + * subtracts two large numbers. */ export const glsl_splitAlongDirection = ` @@ -59,16 +58,13 @@ struct QuadraticNearRoot { }; // Smaller root of quadraticA * t^2 + 2 * quadraticB * t + quadraticC, for a -// quadraticA above zero. quadraticB is half the linear coefficient, which is the -// form a ray against a quadric produces and which keeps the discriminant free of a -// factor of four. +// quadraticA above zero. Note the 2: quadraticB is half the linear coefficient. QuadraticNearRoot nearQuadraticRoot(highp float quadraticA, highp float quadraticB, highp float quadraticC) { highp float discriminant = quadraticB * quadraticB - quadraticA * quadraticC; QuadraticNearRoot root; - // Positive form so that a NaN falls through to no root, and so that sqrt is - // never reached with a negative argument. IEEE floats guarantee the NaN half, - // GLSL ES does not, so that part is defence and not a promise. + // Positive form, so a NaN falls through to no root. GLSL ES does not promise + // IEEE NaN comparison, so this is defence and not a guarantee. if (!(discriminant >= 0.0)) { root.exists = false; root.value = 0.0; diff --git a/src/webgl/raycast_sphere.ts b/src/webgl/raycast_sphere.ts index 06616ed271..f9634c9d09 100644 --- a/src/webgl/raycast_sphere.ts +++ b/src/webgl/raycast_sphere.ts @@ -15,8 +15,7 @@ */ /** - * @file Raycast sphere drawn on a camera-facing quad. The vertex stage bounds the - * sphere with a quad and the fragment stage returns depth and a lighting factor. + * @file Raycast sphere drawn on a camera-facing quad. * * Both halves follow Inigo Quilez's sphere functions * (https://iquilezles.org/articles/intersectors/ and @@ -32,8 +31,7 @@ * notice and this permission notice shall be included in all copies or * substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS". * - * The bound is the sphere's own silhouette, which is exact and needs no margin. - * See `nearQuadraticRoot` in `raycast_shader_lib.ts` for how the intersection + * `nearQuadraticRoot` in `raycast_shader_lib.ts` records how the intersection * differs from the original. */ @@ -45,13 +43,9 @@ export function defineRaycastSphereShader(builder: ShaderBuilder) { // xyz: center, w: radius. builder.addVarying("highp vec4", "vSphere", "flat"); builder.addVertexCode(` -// The quad covering the sphere's screen-space silhouette. -// -// That silhouette is a conic, and its clip-space form is the dual quadric -// M * Q * transpose(M), for M the x, y and w rows of uProjection and Q the dual of -// the sphere. Only five entries are needed, and each reduces to a product of two -// clip-space center components minus radiusSq times a dot product of two rows of -// uProjection. The extent along an axis is then the pair of roots of +// The screen-space silhouette of a sphere is a conic. Its clip-space form is the +// dual quadric M * Q * transpose(M), for M the x, y and w rows of uProjection and Q +// the dual of the sphere. The extent along an axis is the pair of roots of // conicWW * t^2 - 2 * conicCross * t + conicDiagonal. void emitRaycastSphereQuad(highp vec3 center, highp float radius) { highp vec4 clipCenter = uProjection * vec4(center, 1.0); @@ -61,8 +55,7 @@ void emitRaycastSphereQuad(highp vec3 center, highp float radius) { highp vec3 rowZ = vec3(uProjection[0].z, uProjection[1].z, uProjection[2].z); highp vec3 rowW = vec3(uProjection[0].w, uProjection[1].w, uProjection[2].w); - // Each plane distance is largest at the center plus the radius along that - // distance's own gradient. + // Largest at the center plus the radius along each distance's own gradient. if (raycastOutsideDepthRange( raycastDepthPlaneDistances(clipCenter) + radius * vec2(length(rowZ + rowW), length(rowW - rowZ)))) { @@ -71,9 +64,8 @@ void emitRaycastSphereQuad(highp vec3 center, highp float radius) { } // Positive exactly when the sphere clears the eye plane, which is when the conic - // is an ellipse. Otherwise part of the sphere projects arbitrarily far and no - // quad short of the whole viewport covers it. Positive form, so a non-finite - // value takes the whole viewport rather than emitting a garbage quad. + // is an ellipse. Otherwise part of the sphere projects arbitrarily far, and no + // quad short of the whole viewport covers it. highp float conicWW = clipCenter.w * clipCenter.w - radiusSq * dot(rowW, rowW); if (!(conicWW > 0.0)) { gl_Position = vec4(getQuadVertexPosition(vec2(-1.0), vec2(1.0)), 0.0, 1.0); @@ -86,7 +78,7 @@ void emitRaycastSphereQuad(highp vec3 center, highp float radius) { highp vec2 conicCross = vec2( clipCenter.x * clipCenter.w - radiusSq * dot(rowX, rowW), clipCenter.y * clipCenter.w - radiusSq * dot(rowY, rowW)); - // The max guards rounding only. A real ellipse cannot give a negative value. + // A real ellipse cannot go negative here, so the max guards rounding only. highp vec2 halfExtent = sqrt(max(conicCross * conicCross - conicDiagonal * conicWW, vec2(0.0))) / conicWW; @@ -108,7 +100,7 @@ void emitRaycastSphereQuad(highp vec3 center, highp float radius) { } void emitRaycastSphere(highp vec3 center, highp float radius) { - // No radius, no surface to hit. A node behind the eye reaches this every frame. + // A center behind the eye is given a zero radius, so this runs every frame. // Positive form, so a non-finite radius culls too. if (!(radius > 0.0)) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); @@ -123,9 +115,8 @@ RaycastHit intersectRaycastPrimitive() { RaycastRay ray = getRaycastRayThroughFragment(); highp float radius = vSphere.w; - // Split along the ray direction, so the parameter below is measured from the - // closest approach to the center. The direction is unit, so the leading - // coefficient is one and the linear term vanishes. + // Splitting along a unit ray direction leaves the leading coefficient one and + // the linear term zero, measured from the closest approach to the center. VectorSplit originSplit = splitAlongDirection(ray.origin - vSphere.xyz, ray.direction); highp float perpendicularDistSq = @@ -134,13 +125,12 @@ RaycastHit intersectRaycastPrimitive() { nearQuadraticRoot(1.0, 0.0, perpendicularDistSq - radius * radius); if (!root.exists) return raycastMiss(); - // The near crossing. Taking the far one would fill the view when the camera - // clips inside the geometry. + // The far crossing would fill the view when the camera clips inside. highp float hitDist = -originSplit.parallelDist + root.value; if (!(hitDist >= 0.0)) return raycastMiss(); - // Hit point minus center, formed from two small terms rather than by subtracting - // the center from a hit point that can be far from the origin. + // Two small terms, rather than a hit point far from the origin minus a center + // just as far from it. return makeRaycastHit( ray.origin + hitDist * ray.direction, originSplit.perpendicular + root.value * ray.direction); diff --git a/src/webgl/raycast_truncated_cone.ts b/src/webgl/raycast_truncated_cone.ts index 52d4e92130..1c9e71ad63 100644 --- a/src/webgl/raycast_truncated_cone.ts +++ b/src/webgl/raycast_truncated_cone.ts @@ -15,15 +15,14 @@ */ /** - * @file Raycast truncated cone drawn on a camera-facing quad. The vertex stage - * bounds the cone with a quad and the fragment stage returns depth and a lighting - * factor. Symbols below say cone for brevity; the surface is always the truncated - * one, and its ends are open. + * @file Raycast truncated cone drawn on a camera-facing quad. Symbols below say + * cone for brevity. The surface is always the truncated one, and its ends are + * open. * - * The radius is given at each end and runs linearly between them. Equal radii give - * an exact cylinder, which is the common case. A cone sized for a constant - * on-screen width needs the taper, because the far end of a receding cone sits at a - * larger radius than the near end. + * The radius is given at each end and runs linearly between them, and equal radii + * give an exact cylinder. A cone sized for a constant on-screen width needs the + * taper, because the far end of a receding cone sits at a larger radius than the + * near end. * * Each end also takes a clip radius, which removes the part of the surface that a * primitive drawn at that end covers. @@ -45,8 +44,8 @@ void emitRaycastCone(highp vec3 endpointA, highp vec3 endpointB, highp float radiusA, highp float radiusB, highp float clipRadiusA, highp float clipRadiusB) { highp float widestRadius = max(radiusA, radiusB); - // No radius, no surface to hit. A segment with both endpoints behind the eye - // reaches this every frame. Positive form, so a non-finite radius culls too. + // A segment with both endpoints behind the eye is given zero radii, so this runs + // every frame. Positive form, so a non-finite radius culls too. if (!(widestRadius > 0.0)) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; @@ -58,9 +57,8 @@ void emitRaycastCone(highp vec3 endpointA, highp vec3 endpointB, highp vec3 axisDirection = axisLength > 1e-6 ? axisVector / axisLength : vec3(0.0, 1.0, 0.0); vConeAxis = vec4(axisDirection, axisLength); - // Two perpendicular radius vectors spanning the widest cross-section. The - // scale comes last: a zero radius would otherwise leave the second cross - // product normalising the zero vector, which GLSL ES leaves undefined. + // Scaling before the second cross product would normalise the zero vector when a + // radius is zero, which GLSL ES leaves undefined. highp vec3 offAxisVector = abs(axisDirection.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0); highp vec3 unitRadiusA = normalize(cross(offAxisVector, axisDirection)); @@ -72,8 +70,8 @@ void emitRaycastCone(highp vec3 endpointA, highp vec3 endpointB, } `); builder.addFragmentCode(` -// Where the surface point falls between the endpoints, 0.0 at A and 1.0 at B. -// Only meaningful once intersectRaycastPrimitive has returned a hit. +// 0.0 at endpoint A and 1.0 at endpoint B. Only meaningful once +// intersectRaycastPrimitive has returned a hit. highp float raycastConeAxialFraction = 0.0; // A surface point sits one local radius from the axis, so its distance to an @@ -88,15 +86,14 @@ bool coneEndClipped(highp float axialDist, highp float radiusAtHit) { } // Across the axis the cone is a circle whose radius grows along the axis, so the -// in-plane test is a quadratic rather than the fixed-radius circle the sphere uses. -// Equal end radii leave the taper rate at zero, and this reduces to that circle. +// in-plane test is a quadratic rather than a fixed-radius circle. RaycastHit intersectRaycastPrimitive() { RaycastRay ray = getRaycastRayThroughFragment(); highp vec3 axisDirection = vConeAxis.xyz; highp float axisLength = vConeAxis.w; highp float radiusA = vConeEndRadii.x; highp float inverseAxisLength = axisLength > 0.0 ? 1.0 / axisLength : 0.0; - // Radius added per unit along the axis. Zero for an exact cylinder. + // Radius added per unit along the axis. highp float taperRate = (vConeEndRadii.y - radiusA) * inverseAxisLength; VectorSplit originSplit = @@ -108,15 +105,13 @@ RaycastHit intersectRaycastPrimitive() { highp float radiusRate = taperRate * directionSplit.parallelDist; // Zero for a ray along the axis, which never meets the surface. Negative for a - // ray running inside the taper angle, where the near crossing lies past the - // apex. Positive form, so a non-finite value misses. This also guards the - // divides below, since a positive value puts perpendicularSpeedSq above zero. + // ray inside the taper angle, where the near crossing lies past the apex. Above + // zero it also puts perpendicularSpeedSq there, guarding the divides below. highp float quadraticA = perpendicularSpeedSq - radiusRate * radiusRate; if (!(quadraticA > 0.0)) return raycastMiss(); - // Measured from the closest approach to the axis, so that the constant term is a - // difference of two small numbers. Neuroglancer models can sit far from the - // origin, and the unshifted form subtracts two large ones. + // The quadratic below is measured from here, so that its constant term is a + // difference of two small numbers. highp float closestDist = -dot(originSplit.perpendicular, directionSplit.perpendicular) / perpendicularSpeedSq; @@ -136,8 +131,8 @@ RaycastHit intersectRaycastPrimitive() { highp float hitDist = closestDist + root.value; if (!(hitDist >= 0.0)) return raycastMiss(); - // Along the axis the cone is an interval. That also holds the radius between the - // two end radii, so a surface past a cone apex never draws. + // The interval test also holds the radius between the two end radii, so a + // surface past a cone apex never draws. highp float axialDist = originSplit.parallelDist + hitDist * directionSplit.parallelDist; if (!(axialDist >= 0.0 && axialDist <= axisLength)) return raycastMiss(); @@ -145,8 +140,7 @@ RaycastHit intersectRaycastPrimitive() { if (coneEndClipped(axialDist, radiusAtHit)) return raycastMiss(); raycastConeAxialFraction = axialDist * inverseAxisLength; - // The gradient of the surface equation. The axial term is what the taper adds, - // and it vanishes for an exact cylinder, leaving the radial direction. + // The gradient of the surface equation. The axial term is what the taper adds. highp vec3 perpendicularAtHit = originSplit.perpendicular + hitDist * directionSplit.perpendicular; return makeRaycastHit( From 4ec39e77c2104cd4522b04d0fce4e7409ca37694 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 2 Sep 2026 00:21:34 +0200 Subject: [PATCH 29/33] refactor: update tests, flow, and glsl for clarity --- python/tests/skeleton_rendering_test.py | 93 ++- src/annotation/point.ts | 2 +- src/skeleton/frontend.ts | 129 ++-- src/webgl/lines.browser_test.ts | 131 ++-- src/webgl/lines.ts | 29 +- src/webgl/raycast_primitive.browser_test.ts | 684 +++++++++++--------- src/webgl/raycast_primitive.ts | 140 ++-- src/webgl/raycast_shader_lib.ts | 59 +- src/webgl/raycast_sphere.ts | 72 ++- src/webgl/raycast_truncated_cone.ts | 125 ++-- 10 files changed, 854 insertions(+), 610 deletions(-) diff --git a/python/tests/skeleton_rendering_test.py b/python/tests/skeleton_rendering_test.py index a8a2a9c1a9..48a626a570 100644 --- a/python/tests/skeleton_rendering_test.py +++ b/python/tests/skeleton_rendering_test.py @@ -11,7 +11,13 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Tests that skeleton rendering can be controlled via ViewerState.""" +"""Screenshot tests for skeleton rendering. + +`test_skeleton_options` checks that a skeleton layer draws, and that turning its +subsource off stops it. `test_skeleton_render_mode` checks that each render mode +produces the shading it is supposed to, and that the modes which enlarge the nodes +cover more than the ones that do not. +""" import neuroglancer import neuroglancer.skeleton @@ -111,10 +117,28 @@ def test_skeleton_options(webdriver): assert_solid_color(screenshot_pixels(webdriver, 10), [0, 0, 0, 255]) -# Each entry pairs a mode with the only shading signature it produces. -FEATHERED = "feathered" # slice view feathers the line edge -FLAT = "flat" # no feather outside the slice view -LIT = "lit" # shaded by the surface normal +# Each entry pairs a mode with the only shading signature it produces. What +# separates them is how the brightness of the drawn pixels is distributed, not how +# dark the darkest one is: a feathered edge and a lit surface both reach down toward +# zero, so the minimum alone cannot tell them apart. +# +# FLAT every drawn pixel is fully bright +# FEATHERED a majority fully bright, with a thin partial rim +# LIT few fully bright, because the surface normal turns across the whole +# surface. The lighting factor is `abs(dot(normal, light)) + ambient` +# with ambient 0.2 and directional 0.8, so a lit pixel runs over +# [0.2, 1.0] of full brightness. +FEATHERED = "feathered" +FLAT = "flat" +LIT = "lit" + +FULL_BRIGHTNESS = 255 +# A feathered rim is a perimeter effect, so most of the line is still fully bright. +MIN_FULLY_BRIGHT_FRACTION_WHEN_FEATHERED = 0.5 +# A lit surface varies everywhere, so almost nothing sits at exactly full. +MAX_FULLY_BRIGHT_FRACTION_WHEN_LIT = 0.2 +# Lighting runs over [0.2, 1.0], and a side-on view sweeps most of that range. +MIN_BRIGHTNESS_SPREAD_WHEN_LIT = 0.4 * FULL_BRIGHTNESS RENDER_MODES = [ ("xy", "lines", FEATHERED), @@ -146,22 +170,57 @@ def test_skeleton_render_mode(webdriver): ) red, green, blue = (image[..., i].astype(int) for i in range(3)) # A pure red shader leaves the other channels untouched in every mode. - np.testing.assert_array_equal(green, 0, err_msg=case) - np.testing.assert_array_equal(blue, 0, err_msg=case) + np.testing.assert_array_equal( + green, 0, err_msg=f"{case} put light in the green channel" + ) + np.testing.assert_array_equal( + blue, 0, err_msg=f"{case} put light in the blue channel" + ) drawn_red = red[red != 0] - assert len(drawn_red) > 200, f"{case} drew nothing recognisable" + assert len(drawn_red) > 200, ( + f"{case} drew {len(drawn_red)} pixels, too few to judge the shading" + ) drawn_counts[(layout, mode)] = len(drawn_red) - if shading is LIT: - assert drawn_red.min() < 250, f"{case} is flat, so it is not lit" - assert drawn_red.max() == 255, case - elif shading is FLAT: - np.testing.assert_array_equal(drawn_red, 255, err_msg=case) + fully_bright = (drawn_red == FULL_BRIGHTNESS).mean() + brightest, darkest = drawn_red.max(), drawn_red.min() + + if shading is FLAT: + np.testing.assert_array_equal( + drawn_red, + FULL_BRIGHTNESS, + err_msg=( + f"{case} should shade nothing, so every drawn pixel should be " + f"{FULL_BRIGHTNESS}, but they run {darkest} to {brightest}" + ), + ) + elif shading is FEATHERED: + assert fully_bright < 1.0, ( + f"{case} has every drawn pixel at {FULL_BRIGHTNESS}, so its edge " + "is not feathered" + ) + assert fully_bright > MIN_FULLY_BRIGHT_FRACTION_WHEN_FEATHERED, ( + f"{case} has only {fully_bright:.0%} of drawn pixels at full " + "brightness. A feather is a rim, so the interior should stay full. " + "This looks like shading across the whole surface" + ) else: - assert drawn_red.min() < 255, f"{case} has no feathered edge" - assert drawn_red.max() == 255, case + assert fully_bright < MAX_FULLY_BRIGHT_FRACTION_WHEN_LIT, ( + f"{case} has {fully_bright:.0%} of drawn pixels at full brightness. " + "A lit surface turns its normal everywhere, so few should be flat " + "out. This looks like a feathered edge on flat colour" + ) + assert brightest - darkest > MIN_BRIGHTNESS_SPREAD_WHEN_LIT, ( + f"{case} spans only {brightest - darkest} brightness levels " + f"({darkest} to {brightest}). Lighting runs over " + f"[0.2, 1.0], so a side-on view should sweep most of it" + ) for layout, plain, enlarged in ENLARGED_PAIRS: - assert drawn_counts[(layout, enlarged)] > drawn_counts[(layout, plain)], ( - f"{layout}/{enlarged} covers no more than {layout}/{plain}" + plain_count = drawn_counts[(layout, plain)] + enlarged_count = drawn_counts[(layout, enlarged)] + assert enlarged_count > plain_count, ( + f"{layout}/{enlarged} drew {enlarged_count} pixels against " + f"{plain_count} for {layout}/{plain}. Enlarging the nodes should cover " + "strictly more" ) diff --git a/src/annotation/point.ts b/src/annotation/point.ts index 27b12f1491..7d04614b54 100644 --- a/src/annotation/point.ts +++ b/src/annotation/point.ts @@ -110,7 +110,7 @@ emitAnnotation(color); `annotation/point:2d:${extraDim}`, (builder: ShaderBuilder) => { defineVertexId(builder); - defineLineShader(builder, /*rounded=*/ true); + defineLineShader(builder, { rounded: true }); this.defineShaderCommon(builder); builder.addVertexMain(` vec3 subspacePositionA = projectModelVectorToSubspace(modelPosition); diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index fb397ac427..5bf807937b 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -107,8 +107,8 @@ import { import { defineVertexId, VertexIdHelper } from "#src/webgl/vertex_id.js"; const tempModelClip = mat4.create(); -const tempDisplayClip = mat4.create(); -const tempModelToDisplay = mat4.create(); +const tempCanonicalVoxelClip = mat4.create(); +const tempModelToCanonicalVoxel = mat4.create(); const tempCanonicalVoxelScaleMatrix = mat4.create(); const tempCanonicalVoxelScale = vec3.create(); const tempInverseCanonicalVoxelScale = vec3.create(); @@ -146,13 +146,27 @@ function hasEnlargedNodes(mode: SkeletonRenderMode) { ); } -// Pooled across frames, so a steady view allocates nothing. interface VisibleSkeletonToDraw { skeleton: SkeletonChunk; pickIndex: number; readonly color: Float32Array; } +// What one draw call covers, which decides how a vertex attribute reaches the +// fragment shader. +enum SkeletonShaderGeometry { + // A quad whose two ends are the edge's endpoints, so the rasteriser interpolates. + LINE_QUAD = 0, + // One quad per node, reading that node's attribute. + CIRCLE_QUAD = 1, + // A bounding quad whose vertices are corners, not endpoints. Nothing to + // interpolate from, so the fragment shader has to do it: two flat varyings per + // attribute instead of one, mixed by where the hit falls along the cone. + RAYCAST_CONE = 2, + // One bounding quad per node, as CIRCLE_QUAD. + RAYCAST_SPHERE = 3, +} + interface VertexAttributeRenderInfo extends VertexAttributeInfo { name: string; webglDataType: number; @@ -264,15 +278,15 @@ highp vec3 vertexB = readAttribute0(aVertexIndex.y); if (useRaycast) { defineRaycastConeShader(builder); builder.addUniform("highp float", "uEdgePixelRadius"); - builder.addUniform("highp mat4", "uModelToDisplay"); + builder.addUniform("highp mat4", "uModelToCanonicalVoxel"); vertexMain += ` -highp vec3 displayVertexA = (uModelToDisplay * vec4(vertexA, 1.0)).xyz; -highp vec3 displayVertexB = (uModelToDisplay * vec4(vertexB, 1.0)).xyz; +highp vec3 canonicalVertexA = (uModelToCanonicalVoxel * vec4(vertexA, 1.0)).xyz; +highp vec3 canonicalVertexB = (uModelToCanonicalVoxel * vec4(vertexB, 1.0)).xyz; highp vec2 edgeRadii = getRaycastSegmentRadiiForPixels( - displayVertexA, displayVertexB, uEdgePixelRadius); -emitRaycastCone(displayVertexA, displayVertexB, edgeRadii.x, edgeRadii.y, - getRaycastRadiusForPixels(displayVertexA, uNodeClipPixelRadius), - getRaycastRadiusForPixels(displayVertexB, uNodeClipPixelRadius)); + canonicalVertexA, canonicalVertexB, uEdgePixelRadius); +emitRaycastCone(canonicalVertexA, canonicalVertexB, edgeRadii.x, edgeRadii.y, + getRaycastRadiusForPixels(canonicalVertexA, uNodeClipPixelRadius), + getRaycastRadiusForPixels(canonicalVertexB, uNodeClipPixelRadius)); `; builder.addFragmentCode(` void emitRGB(vec3 color) { @@ -284,7 +298,7 @@ void emitDefault() { } `); } else { - defineLineShader(builder, /*rounded=*/ false, /*endpointClipping=*/ true); + defineLineShader(builder, { endpointClipping: true }); builder.addUniform("highp float", "uLineWidth"); vertexMain += ` emitLine(uProjection, vertexA, vertexB, uLineWidth, uNodeClipPixelRadius); @@ -304,8 +318,9 @@ void emitDefault() { builder, shaderBuilderState, vertexMain, - useRaycast, - useRaycast ? "raycastConeAxialFraction" : undefined, + useRaycast + ? SkeletonShaderGeometry.RAYCAST_CONE + : SkeletonShaderGeometry.LINE_QUAD, ); } @@ -322,12 +337,12 @@ highp vec3 vertexPosition = readAttribute0(vertexIndex); if (useRaycast) { defineRaycastSphereShader(builder); builder.addUniform("highp float", "uNodePixelRadius"); - builder.addUniform("highp mat4", "uModelToDisplay"); + builder.addUniform("highp mat4", "uModelToCanonicalVoxel"); vertexMain += ` -highp vec3 displayPosition = (uModelToDisplay * vec4(vertexPosition, 1.0)).xyz; +highp vec3 canonicalPosition = (uModelToCanonicalVoxel * vec4(vertexPosition, 1.0)).xyz; emitRaycastSphere( - displayPosition, - getRaycastRadiusForPixels(displayPosition, uNodePixelRadius)); + canonicalPosition, + getRaycastRadiusForPixels(canonicalPosition, uNodePixelRadius)); `; builder.addFragmentCode(` void emitRGBA(vec4 color) { @@ -358,28 +373,32 @@ void emitDefault() { builder, shaderBuilderState, vertexMain, - useRaycast, + useRaycast + ? SkeletonShaderGeometry.RAYCAST_SPHERE + : SkeletonShaderGeometry.CIRCLE_QUAD, ); } - // `edgeMixExpression` is set only where one draw covers a whole edge, as the cone - // does, and gives where the fragment falls between the two ends. private finalizeShaderBuilder( builder: ShaderBuilder, shaderBuilderState: ShaderControlsBuilderState, vertexMain: string, - useRaycast: boolean, - edgeMixExpression?: string, + geometry: SkeletonShaderGeometry, ) { if (shaderBuilderState.parseResult.errors.length !== 0) { throw new Error("Invalid UI control specification"); } + const useRaycast = + geometry === SkeletonShaderGeometry.RAYCAST_CONE || + geometry === SkeletonShaderGeometry.RAYCAST_SPHERE; + const interpolateInFragment = + geometry === SkeletonShaderGeometry.RAYCAST_CONE; builder.addFragmentCode(glsl_COLORMAPS); const { vertexAttributes } = this; for (let i = 1; i < vertexAttributes.length; ++i) { const info = vertexAttributes[i]; let attributeExpression: string; - if (edgeMixExpression === undefined) { + if (!interpolateInFragment) { builder.addVarying(`highp ${info.glslDataType}`, `vCustom${i}`); vertexMain += `vCustom${i} = readAttribute${i}(vertexIndex);\n`; attributeExpression = `vCustom${i}`; @@ -396,7 +415,7 @@ void emitDefault() { ); vertexMain += `vCustomA${i} = readAttribute${i}(aVertexIndex.x);\n`; vertexMain += `vCustomB${i} = readAttribute${i}(aVertexIndex.y);\n`; - attributeExpression = `mix(vCustomA${i}, vCustomB${i}, ${edgeMixExpression})`; + attributeExpression = `mix(vCustomA${i}, vCustomB${i}, raycastConeAxialFraction)`; } builder.addFragmentCode(`#define ${info.name} ${attributeExpression}\n`); builder.addFragmentCode( @@ -473,10 +492,11 @@ void emitDefault() { this.vertexIdHelper.enable(); } - // The raycast solves a true sphere in display space, the global space scaled to - // canonical voxels. Solving in layer space would draw every node of an - // anisotropic dataset as an ellipsoid. The light direction is given in the same - // space, so the surface normal needs no further transform. + // The raycast solves a true sphere, so it needs a space with no anisotropic + // scale left in it. Global coordinates scaled to canonical voxels is that space. + // Solving in layer coordinates would draw every node of an anisotropic dataset as + // an ellipsoid. The light direction is given in the same space, so the surface + // normal needs no further transform. private setRaycastUniforms( gl: GL, shader: ShaderProgram, @@ -492,22 +512,26 @@ void emitDefault() { canonicalVoxelFactors[1], canonicalVoxelFactors[2], ); - const modelToDisplay = mat4.multiply( - tempModelToDisplay, + const modelToCanonicalVoxel = mat4.multiply( + tempModelToCanonicalVoxel, mat4.fromScaling(tempCanonicalVoxelScaleMatrix, canonicalVoxelScale), modelMatrix, ); - const displayClip = mat4.scale( - tempDisplayClip, + const canonicalVoxelClip = mat4.scale( + tempCanonicalVoxelClip, projectionParameters.viewProjectionMat, vec3.inverse(tempInverseCanonicalVoxelScale, canonicalVoxelScale), ); gl.uniformMatrix4fv( - shader.uniform("uModelToDisplay"), + shader.uniform("uModelToCanonicalVoxel"), false, - modelToDisplay, + modelToCanonicalVoxel, + ); + initializeRaycastPrimitiveShader( + shader, + canonicalVoxelClip, + projectionParameters, ); - initializeRaycastPrimitiveShader(shader, displayClip, projectionParameters); const { lightDirection, ambientLighting, directionalLighting } = renderContext as PerspectiveViewRenderContext; gl.uniform4f( @@ -586,22 +610,20 @@ void emitDefault() { } } + // Held between beginEdges and endEdges, which bracket the edge pass the way + // beginLayer and endLayer bracket the whole draw. + private edgeAttributeIndex = -1; + beginEdges(shader: ShaderProgram) { const { gl } = this; - const aVertexIndex = shader.attribute("aVertexIndex"); - gl.vertexAttribDivisor(aVertexIndex, 1); - return aVertexIndex; + this.edgeAttributeIndex = shader.attribute("aVertexIndex"); + gl.vertexAttribDivisor(this.edgeAttributeIndex, 1); } - drawEdges( - gl: GL, - shader: ShaderProgram, - aVertexIndex: number, - skeletonChunk: SkeletonChunk, - ) { + drawEdges(gl: GL, shader: ShaderProgram, skeletonChunk: SkeletonChunk) { this.bindVertexAttributeTextures(gl, shader, skeletonChunk); skeletonChunk.indexBuffer.bindToVertexAttribI( - aVertexIndex, + this.edgeAttributeIndex, 2, WebGL2RenderingContext.UNSIGNED_INT, ); @@ -613,10 +635,11 @@ void emitDefault() { } } - endEdges(aVertexIndex: number) { + endEdges() { const { gl } = this; - gl.vertexAttribDivisor(aVertexIndex, 0); - gl.disableVertexAttribArray(aVertexIndex); + gl.vertexAttribDivisor(this.edgeAttributeIndex, 0); + gl.disableVertexAttribArray(this.edgeAttributeIndex); + this.edgeAttributeIndex = -1; } // Nodes are drawn in every render mode so that there are no visible gaps @@ -855,11 +878,11 @@ export class SkeletonLayer extends RefCounted { lineWidth, nodeDiameter, ); - const aVertexIndex = renderHelper.beginEdges(edgeShader); + renderHelper.beginEdges(edgeShader); this.drawPass(renderContext, renderHelper, edgeShader, (skeleton) => - renderHelper.drawEdges(gl, edgeShader, aVertexIndex, skeleton), + renderHelper.drawEdges(gl, edgeShader, skeleton), ); - renderHelper.endEdges(aVertexIndex); + renderHelper.endEdges(); nodeShader.bind(); renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); @@ -930,7 +953,7 @@ export class SkeletonLayer extends RefCounted { renderContext: SliceViewPanelRenderContext | PerspectiveViewRenderContext, renderHelper: RenderHelper, shader: ShaderProgram, - drawChunk: (skeleton: SkeletonChunk) => void, + drawCallback: (skeleton: SkeletonChunk) => void, ) { const { gl, visibleSkeletons, visibleSkeletonCount } = this; const { emitColor, emitPickID } = renderContext; @@ -938,7 +961,7 @@ export class SkeletonLayer extends RefCounted { const entry = visibleSkeletons[i]; if (emitColor) renderHelper.setColor(gl, shader, entry.color); if (emitPickID) renderHelper.setPickID(gl, shader, entry.pickIndex); - drawChunk(entry.skeleton); + drawCallback(entry.skeleton); } } diff --git a/src/webgl/lines.browser_test.ts b/src/webgl/lines.browser_test.ts index 4dfe51c24e..a92e36b88e 100644 --- a/src/webgl/lines.browser_test.ts +++ b/src/webgl/lines.browser_test.ts @@ -25,25 +25,31 @@ import { ShaderBuilder } from "#src/webgl/shader.js"; import { webglTest } from "#src/webgl/testing.js"; import { defineVertexId, VertexIdHelper } from "#src/webgl/vertex_id.js"; -const VIEWPORT_SIZE = 64; -const LINE_WIDTH_IN_PIXELS = 6; -const CLIP_RADIUS_IN_PIXELS = 10; +// A 64 pixel square viewport. Endpoints are given in clip space, so a test can put +// one outside the depth range without setting up a projection. +const VIEWPORT = 64; -// `endpointsClip` gives both endpoints in clip space, so a test can put an endpoint -// outside the depth range without setting up a projection. -function drawClippedLine( - gl: GL, - endpointsClip: string, - clipRadiusInPixels: number, -): Uint8Array { - const size = VIEWPORT_SIZE; +type ClipPoint = readonly [number, number, number, number]; + +interface LineSpec { + readonly endpointA: ClipPoint; + readonly endpointB: ClipPoint; + readonly widthInPixels: number; + readonly clipRadiusInPixels: number; +} + +function glslClipPoint(point: ClipPoint): string { + return `vec4(${point.map((value) => value.toFixed(4)).join(", ")})`; +} + +function drawLine(gl: GL, spec: LineSpec): Uint8Array { const builder = new ShaderBuilder(gl); builder.addOutputBuffer("vec4", "out_color", 0); defineVertexId(builder); - defineLineShader(builder, /*rounded=*/ false, /*endpointClipping=*/ true); + defineLineShader(builder, { endpointClipping: true }); builder.setVertexMain( - `emitLine(${endpointsClip}, ${LINE_WIDTH_IN_PIXELS.toFixed(1)}, ` + - `${clipRadiusInPixels.toFixed(1)});`, + `emitLine(${glslClipPoint(spec.endpointA)}, ${glslClipPoint(spec.endpointB)}, + ${spec.widthInPixels.toFixed(1)}, ${spec.clipRadiusInPixels.toFixed(1)});`, ); builder.setFragmentMain("out_color = vec4(getLineAlpha());\n"); const shader = builder.build(); @@ -53,73 +59,92 @@ function drawClippedLine( vertexIdHelper.enable(); initializeLineShader( shader, - { width: size, height: size }, + { width: VIEWPORT, height: VIEWPORT }, /*featherWidthInPixels=*/ 0, ); - gl.viewport(0, 0, size, size); + gl.viewport(0, 0, VIEWPORT, VIEWPORT); gl.clearColor(0, 0, 0, 0); gl.clear(WebGL2RenderingContext.COLOR_BUFFER_BIT); drawLines(gl, 1, 1); - const pixels = new Uint8Array(size * size * 4); + const pixels = new Uint8Array(VIEWPORT * VIEWPORT * 4); gl.readPixels( 0, 0, - size, - size, + VIEWPORT, + VIEWPORT, WebGL2RenderingContext.RGBA, WebGL2RenderingContext.UNSIGNED_BYTE, pixels, ); - const covered = new Uint8Array(size * size); - for (let i = 0; i < size * size; ++i) { - covered[i] = pixels[i * 4] !== 0 ? 1 : 0; - } - return covered; + return pixels; } finally { vertexIdHelper.disable(); shader.dispose(); } } -function countCovered(covered: Uint8Array): number { - let total = 0; - for (const value of covered) total += value; - return total; +function coveredCount(pixels: Uint8Array): number { + let covered = 0; + for (let i = 0; i < VIEWPORT * VIEWPORT; ++i) { + if (pixels[i * 4] !== 0) ++covered; + } + return covered; } -function isCovered(covered: Uint8Array, x: number, y: number): boolean { - return covered[y * VIEWPORT_SIZE + x] === 1; +function isCovered(pixels: Uint8Array, x: number, y: number): boolean { + return pixels[(y * VIEWPORT + x) * 4] !== 0; } describe("line endpoint clipping", () => { - // A clip disc belongs at each endpoint, so that a node drawn there has room. - it("removes a disc at each endpoint", () => { + it("removes a disc at each endpoint, so a node drawn there has room", () => { webglTest((gl) => { - const endpoints = "vec4(-0.5, 0.0, 0.0, 1.0), vec4(0.5, 0.0, 0.0, 1.0)"; - const unclipped = drawClippedLine(gl, endpoints, 0); - const clipped = drawClippedLine(gl, endpoints, CLIP_RADIUS_IN_PIXELS); - expect(countCovered(clipped)).toBeGreaterThan(0); - expect(countCovered(clipped)).toBeLessThan(countCovered(unclipped)); - // Endpoint A sits at NDC x of -0.5, which is a quarter across the viewport. - const endpointAX = VIEWPORT_SIZE / 4; - const centerY = VIEWPORT_SIZE / 2; - expect(isCovered(unclipped, endpointAX, centerY)).toBe(true); - expect(isCovered(clipped, endpointAX, centerY)).toBe(false); + // Horizontal across the middle, from NDC x of -0.5 to 0.5. Endpoint A lands + // a quarter across the viewport, at pixel 16. + const spec = { + endpointA: [-0.5, 0, 0, 1], + endpointB: [0.5, 0, 0, 1], + widthInPixels: 6, + } as const; + const unclipped = drawLine(gl, { ...spec, clipRadiusInPixels: 0 }); + const clipped = drawLine(gl, { ...spec, clipRadiusInPixels: 10 }); + + expect(coveredCount(clipped)).toBeGreaterThan(0); + expect(coveredCount(clipped)).toBeLessThan(coveredCount(unclipped)); + expect(isCovered(unclipped, 16, VIEWPORT / 2)).toBe(true); + expect(isCovered(clipped, 16, VIEWPORT / 2)).toBe(false); + }); + }); + + it("measures the disc from the endpoints as given, not the clipped ends", () => { + webglTest((gl) => { + // z runs -3 to 3, so only the middle third of the line survives the depth + // range. Measuring the disc from those moved ends would eat the drawn line + // where no node exists, the node itself having been clipped away with the + // rest of the segment. Both given endpoints end up more than one clip radius + // clear of what is drawn, so the discs must remove nothing. + const spec = { + endpointA: [-1, 0, -3, 1], + endpointB: [1, 0, 3, 1], + widthInPixels: 6, + } as const; + const unclipped = drawLine(gl, { ...spec, clipRadiusInPixels: 0 }); + const clipped = drawLine(gl, { ...spec, clipRadiusInPixels: 10 }); + + expect(coveredCount(unclipped)).toBeGreaterThan(0); + expect(coveredCount(clipped)).toBe(coveredCount(unclipped)); }); }); - // Measuring the disc from the depth-clipped ends would eat the drawn line where - // no node exists, the node itself having been clipped away with the segment. - it("measures from the given endpoints, not the depth-clipped ones", () => { + it("rejects endpoint clipping on a rounded line", () => { webglTest((gl) => { - // z runs from -3 to 3, so only the middle third survives the depth range. - // Both given endpoints end up more than one clip radius clear of what is - // drawn, so the discs must remove nothing. - const endpoints = "vec4(-1.0, 0.0, -3.0, 1.0), vec4(1.0, 0.0, 3.0, 1.0)"; - const unclipped = drawClippedLine(gl, endpoints, 0); - const clipped = drawClippedLine(gl, endpoints, CLIP_RADIUS_IN_PIXELS); - expect(countCovered(unclipped)).toBeGreaterThan(0); - expect(countCovered(clipped)).toBe(countCovered(unclipped)); + // The clip lives in getLineAlpha, which a rounded line never calls, so the + // pair would silently ignore the clip radius. + expect(() => + defineLineShader(new ShaderBuilder(gl), { + rounded: true, + endpointClipping: true, + }), + ).toThrow(/rounded/); }); }); }); diff --git a/src/webgl/lines.ts b/src/webgl/lines.ts index 3c4abb3ba0..5113835933 100644 --- a/src/webgl/lines.ts +++ b/src/webgl/lines.ts @@ -28,21 +28,25 @@ import { glsl_clipLineToDepthRange } from "#src/webgl/shader_lib.js"; export const VERTICES_PER_LINE = VERTICES_PER_QUAD; -/** - * @param rounded adds a borderWidth argument to emitLine. - * @param endpointClipping adds an endpointClipRadiusInPixels argument to emitLine, - * and discards fragments within that radius of either endpoint. - */ +export interface LineShaderOptions { + /** Adds a borderWidth argument to emitLine, and rounds the two ends. */ + readonly rounded?: boolean; + /** + * Adds an endpointClipRadiusInPixels argument to emitLine, and discards + * fragments within that radius of either endpoint as it was given. + */ + readonly endpointClipping?: boolean; +} + export function defineLineShader( builder: ShaderBuilder, - rounded = false, - endpointClipping = false, + options: LineShaderOptions = {}, ) { - // The clip lives in getLineAlpha, which a rounded line never calls. Rejecting - // the pair beats ignoring the clip radius without a word. + const { rounded = false, endpointClipping = false } = options; if (rounded && endpointClipping) { throw new Error( - "defineLineShader does not support endpoint clipping on rounded lines.", + "defineLineShader does not support endpoint clipping on rounded lines. " + + "The clip lives in getLineAlpha, which a rounded line never calls.", ); } builder.addVertexCode(glsl_getQuadVertexPosition); @@ -71,9 +75,8 @@ export function defineLineShader( builder.addVertexCode(` ${ endpointClipping - ? `// Far off screen for a point at or behind the eye, which has no window position -// and so no clip disc to draw. -highp vec2 lineClipToWindow(vec4 clip) { + ? `highp vec2 lineClipToWindow(vec4 clip) { + // Far off screen for a point at or behind the eye, which has no clip disc. if (!(clip.w > 0.0)) return vec2(-1e6); return (clip.xy / clip.w * 0.5 + 0.5) / uLineParams.xy; }` diff --git a/src/webgl/raycast_primitive.browser_test.ts b/src/webgl/raycast_primitive.browser_test.ts index 6b8f44a442..914e3b988d 100644 --- a/src/webgl/raycast_primitive.browser_test.ts +++ b/src/webgl/raycast_primitive.browser_test.ts @@ -28,42 +28,55 @@ import { ShaderBuilder } from "#src/webgl/shader.js"; import { webglTest } from "#src/webgl/testing.js"; import { defineVertexId, VertexIdHelper } from "#src/webgl/vertex_id.js"; -function buildShader( - definePrimitive: (builder: ShaderBuilder) => void, - emitPrimitive: string, -) { - webglTest((gl) => { - const builder = new ShaderBuilder(gl); - builder.addOutputBuffer("vec4", "out_color", 0); - definePrimitive(builder); - builder.setVertexMain(emitPrimitive); - // Mirrors how a consumer emits: from a helper function, which can only see - // the published globals and not main's locals. - builder.addFragmentCode(` -void emitShaded() { - out_color = vec4(vec3(raycastLightingFactor), raycastSurfaceDepth); +// A 64 pixel square viewport, and a camera at the origin looking down -z with a +// 45 degree vertical field of view. So a z of -1 is one unit ahead, and a radius r +// there spans r / tan(22.5 degrees) in NDC, which is 2.414 * r. +const VIEWPORT = 64; +const FIELD_OF_VIEW = Math.PI / 4; + +// Writes for every fragment the bounding quad rasterises, so the result measures +// the quad the vertex stage emitted. +const SHADE_WHOLE_QUAD = "out_color = vec4(1.0);\n"; +// Runs the real hit test first, so a miss discards and the result measures the +// primitive's own surface. +const SHADE_SURFACE = `${glsl_raycastFragmentSetup}out_color = vec4(1.0);\n`; +// Red carries the axial fraction. Green marks a fragment that survived the hit +// test, since a fraction of zero is indistinguishable from an unwritten pixel. +const SHADE_AXIAL_FRACTION = `${glsl_raycastFragmentSetup}out_color = vec4(raycastConeAxialFraction, 1.0, 0.0, 1.0);\n`; + +type Point = readonly [number, number, number]; +// A radius may be a plain number, or a GLSL expression for the tests that drive the +// pixel-radius helpers. +type Radius = number | string; + +interface ConeSpec { + readonly endpointA: Point; + readonly endpointB: Point; + readonly radiusA: Radius; + readonly radiusB: Radius; + readonly clipRadiusA?: Radius; + readonly clipRadiusB?: Radius; } -`); - builder.setFragmentMain(glsl_raycastFragmentSetup + "emitShaded();\n"); - builder.build().dispose(); - }); + +interface SphereSpec { + readonly center: Point; + readonly radius: Radius; +} + +function glslPoint(point: Point): string { + return `vec3(${point.map((value) => value.toFixed(4)).join(", ")})`; } -// A camera at the raycast-space origin looking down -z, so a raycast-space z of -// -1 is one unit in front of the camera. -const COVERAGE_VIEWPORT_SIZE = 64; -const COVERAGE_NEAR_BOUND = 0.1; -const COVERAGE_FAR_BOUND = 20; -// Radius of the cone and ball that the shaded tests draw. -const PRIMITIVE_TEST_RADIUS = "0.05"; +function glslRadius(radius: Radius): string { + return typeof radius === "number" ? radius.toFixed(5) : radius; +} -function renderPrimitive( +function render( gl: GL, definePrimitive: (builder: ShaderBuilder) => void, emitPrimitive: string, fragmentMain: string, ): Uint8Array { - const size = COVERAGE_VIEWPORT_SIZE; const builder = new ShaderBuilder(gl); builder.addOutputBuffer("vec4", "out_color", 0); defineVertexId(builder); @@ -75,27 +88,21 @@ function renderPrimitive( try { shader.bind(); vertexIdHelper.enable(); - const projectionMatrix = mat4.perspective( - mat4.create(), - Math.PI / 4, - 1, - COVERAGE_NEAR_BOUND, - COVERAGE_FAR_BOUND, + initializeRaycastPrimitiveShader( + shader, + mat4.perspective(mat4.create(), FIELD_OF_VIEW, 1, 0.1, 20), + { width: VIEWPORT, height: VIEWPORT }, ); - initializeRaycastPrimitiveShader(shader, projectionMatrix, { - width: size, - height: size, - }); - gl.viewport(0, 0, size, size); + gl.viewport(0, 0, VIEWPORT, VIEWPORT); gl.clearColor(0, 0, 0, 0); gl.clear(WebGL2RenderingContext.COLOR_BUFFER_BIT); drawQuads(gl, 1, 1); - const pixels = new Uint8Array(size * size * 4); + const pixels = new Uint8Array(VIEWPORT * VIEWPORT * 4); gl.readPixels( 0, 0, - size, - size, + VIEWPORT, + VIEWPORT, WebGL2RenderingContext.RGBA, WebGL2RenderingContext.UNSIGNED_BYTE, pixels, @@ -107,240 +114,249 @@ function renderPrimitive( } } -// The fragment shader writes unconditionally, so this measures the vertex stage -// alone. An out-of-range quad is counted here but discarded by the real shader, -// which makes it invisible to any test of the shaded result. -function measureQuadCoverage( - gl: GL, - definePrimitive: (builder: ShaderBuilder) => void, - emitPrimitive: string, -): number { - const pixels = renderPrimitive( +function drawCone(gl: GL, spec: ConeSpec, fragmentMain: string): Uint8Array { + const { clipRadiusA = 0, clipRadiusB = 0 } = spec; + return render( gl, - definePrimitive, - emitPrimitive, - "out_color = vec4(1.0, 1.0, 1.0, 1.0);\n", + defineRaycastConeShader, + `emitRaycastCone(${glslPoint(spec.endpointA)}, ${glslPoint(spec.endpointB)}, + ${glslRadius(spec.radiusA)}, ${glslRadius(spec.radiusB)}, + ${glslRadius(clipRadiusA)}, ${glslRadius(clipRadiusB)});`, + fragmentMain, ); - const size = COVERAGE_VIEWPORT_SIZE; - let covered = 0; - for (let i = 0; i < size * size; ++i) { - if (pixels[i * 4] !== 0) ++covered; - } - return covered / (size * size); } -// Runs the real fragment setup, so a miss discards. Unlike quad coverage this -// measures the surface, so it falls if a bounding quad clips the primitive. -function measureShadedCoverage( +function drawSphere( gl: GL, - definePrimitive: (builder: ShaderBuilder) => void, - emitPrimitive: string, -): number { - const pixels = renderPrimitive( + spec: SphereSpec, + fragmentMain: string, +): Uint8Array { + return render( gl, - definePrimitive, - emitPrimitive, - glsl_raycastFragmentSetup + "out_color = vec4(1.0, 1.0, 1.0, 1.0);\n", + defineRaycastSphereShader, + `emitRaycastSphere(${glslPoint(spec.center)}, ${glslRadius(spec.radius)});`, + fragmentMain, ); - const size = COVERAGE_VIEWPORT_SIZE; - let shaded = 0; - for (let i = 0; i < size * size; ++i) { - if (pixels[i * 4] !== 0) ++shaded; +} + +function coveredFraction(pixels: Uint8Array): number { + let covered = 0; + for (let i = 0; i < VIEWPORT * VIEWPORT; ++i) { + if (pixels[i * 4] !== 0) ++covered; } - return shaded / (size * size); + return covered / (VIEWPORT * VIEWPORT); } -// `depth` is the raycast-space z, negative for in front of the camera. -function coneCoverage(gl: GL, depth: number) { - return measureQuadCoverage( - gl, - defineRaycastConeShader, - `emitRaycastCone(vec3(0.0, -0.3, ${depth.toFixed(4)}), - vec3(0.0, 0.3, ${depth.toFixed(4)}), - ${PRIMITIVE_TEST_RADIUS}, ${PRIMITIVE_TEST_RADIUS}, - 0.0, 0.0);`, - ); +// readPixels returns rows bottom up, so index 0 is the lowest drawn row. +function coveredWidthByRow(pixels: Uint8Array): number[] { + const widths: number[] = []; + for (let row = 0; row < VIEWPORT; ++row) { + let width = 0; + for (let column = 0; column < VIEWPORT; ++column) { + if (pixels[(row * VIEWPORT + column) * 4 + 1] !== 0) ++width; + } + if (width > 0) widths.push(width); + } + return widths; } -function sphereCoverage(gl: GL, depth: number) { - return measureQuadCoverage( - gl, - defineRaycastSphereShader, - `emitRaycastSphere(vec3(0.0, 0.0, ${depth.toFixed(4)}), ${PRIMITIVE_TEST_RADIUS});`, - ); +// Red of the first shaded pixel in each row, bottom up. Values run 0 to 255. +function axialFractionByRow(pixels: Uint8Array): number[] { + const fractions: number[] = []; + for (let row = 0; row < VIEWPORT; ++row) { + for (let column = 0; column < VIEWPORT; ++column) { + const offset = (row * VIEWPORT + column) * 4; + if (pixels[offset + 1] !== 0) { + fractions.push(pixels[offset]); + break; + } + } + } + return fractions; } -describe("raycast primitives", () => { - it("compiles the sphere shader", () => { - buildShader( - defineRaycastSphereShader, - `emitRaycastSphere(vec3(0.0), getRaycastRadiusForPixels(vec3(0.0), 5.0));`, - ); +describe("raycast cone", () => { + it("publishes its depth and lighting to a consumer's own emit helper", () => { + webglTest((gl) => { + const builder = new ShaderBuilder(gl); + builder.addOutputBuffer("vec4", "out_color", 0); + defineRaycastConeShader(builder); + builder.setVertexMain( + `emitRaycastCone(vec3(0.0), vec3(0.0, 1.0, 0.0), 0.1, 0.2, 0.0, 0.0);`, + ); + // A helper sees the published globals, not main's locals. + builder.addFragmentCode(` +void emitShaded() { + out_color = vec4(raycastLightingFactor, raycastSurfaceDepth, + raycastConeAxialFraction, 1.0); +} +`); + builder.setFragmentMain(`${glsl_raycastFragmentSetup}emitShaded();\n`); + builder.build().dispose(); + }); }); - it("compiles the cone shader", () => { - buildShader( - defineRaycastConeShader, - `emitRaycastCone(vec3(0.0), vec3(0.0, 1.0, 0.0), - getRaycastRadiusForPixels(vec3(0.0), 2.0), - getRaycastRadiusForPixels(vec3(0.0, 1.0, 0.0), 2.0), - 1.0, 1.0);`, - ); + it("bounds a visible cone to a small part of the viewport", () => { + webglTest((gl) => { + // Upright, one unit ahead, 0.6 long and 0.1 across. Its silhouette is about + // 0.6 by 0.1 in raycast units, which is well under a tenth of the viewport. + const pixels = drawCone( + gl, + { + endpointA: [0, -0.3, -1], + endpointB: [0, 0.3, -1], + radiusA: 0.05, + radiusB: 0.05, + }, + SHADE_WHOLE_QUAD, + ); + expect(coveredFraction(pixels)).toBeGreaterThan(0); + expect(coveredFraction(pixels)).toBeLessThan(0.5); + }); }); - it("bounds a cone tightly, and culls one behind the camera", () => { + it("culls a cone behind the camera", () => { webglTest((gl) => { - const visible = coneCoverage(gl, -1); - expect(visible).toBeGreaterThan(0); - expect(visible).toBeLessThan(0.5); - expect(coneCoverage(gl, 1)).toBe(0); + // Positive z is behind the camera, which looks down -z. + const pixels = drawCone( + gl, + { + endpointA: [0, -0.3, 1], + endpointB: [0, 0.3, 1], + radiusA: 0.05, + radiusB: 0.05, + }, + SHADE_WHOLE_QUAD, + ); + expect(coveredFraction(pixels)).toBe(0); }); }); - // The camera sits inside this cone, whose surface then has no bounded screen - // footprint. Covering the viewport instead would shade every pixel of a - // depth-writing fragment shader, once for each such edge. it("culls a cone that wraps the camera", () => { webglTest((gl) => { - const coverage = measureQuadCoverage( + // The axis passes 0.2 in front of the camera and the radius is 0.5, so the + // camera is inside. That surface has no bounded screen footprint, and + // covering the viewport instead would shade every pixel of a depth-writing + // shader once per such edge. + const pixels = drawCone( gl, - defineRaycastConeShader, - `emitRaycastCone(vec3(-1.0, 0.0, -0.2), vec3(1.0, 0.0, -0.2), - 0.5, 0.5, 0.0, 0.0);`, + { + endpointA: [-1, 0, -0.2], + endpointB: [1, 0, -0.2], + radiusA: 0.5, + radiusB: 0.5, + }, + SHADE_WHOLE_QUAD, ); - expect(coverage).toBe(0); + expect(coveredFraction(pixels)).toBe(0); }); }); - // This edge crosses the eye plane, so one endpoint has no on-screen size and its - // own radius is zero. Borrowing the other end's radius keeps the visible half. - it("keeps an edge whose endpoint has passed behind the camera", () => { + it("culls a cone with no radius", () => { webglTest((gl) => { - const endpoints = "vec3(-0.3, -0.2, -1.0), vec3(0.5, 0.4, 1.0)"; - const coverage = (radii: string) => - measureQuadCoverage( - gl, - defineRaycastConeShader, - `emitRaycastCone(${endpoints}, ${radii}, 0.0, 0.0);`, - ); - // Endpoint B is behind the eye, so its own radius alone leaves nothing. - expect( - coverage("0.0, getRaycastRadiusForPixels(vec3(0.5, 0.4, 1.0), 1.0)"), - ).toBe(0); - const borrowed = coverage( - `getRaycastSegmentRadiiForPixels(${endpoints}, 1.0).x, - getRaycastSegmentRadiiForPixels(${endpoints}, 1.0).y`, + // Both radius helpers return zero for a point at or behind the eye, so this + // runs every frame on a skeleton with geometry behind the camera. There is + // no surface to hit, so shading its quad would be pure waste. + const pixels = drawCone( + gl, + { + endpointA: [0, -0.3, -1], + endpointB: [0, 0.3, -1], + radiusA: 0, + radiusB: 0, + }, + SHADE_WHOLE_QUAD, ); - expect(borrowed).toBeGreaterThan(0.25); - expect(borrowed).toBeLessThan(1); + expect(coveredFraction(pixels)).toBe(0); }); }); - // Endpoint A is the lower end and readPixels returns rows bottom up, so the - // result runs from endpoint A to endpoint B. Values are 0 to 255. - function renderUprightCone( - gl: GL, - radiusA: string, - radiusB: string, - clipRadiusA: number, - clipRadiusB: number, - ): Uint8Array { - return renderPrimitive( - gl, - defineRaycastConeShader, - `emitRaycastCone(vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0), - ${radiusA}, ${radiusB}, ${clipRadiusA.toFixed(4)}, - ${clipRadiusB.toFixed(4)});`, - glsl_raycastFragmentSetup + - "out_color = vec4(raycastConeAxialFraction, 1.0, 0.0, 1.0);\n", - ); - } - - function shadedConeAxialFractionByRow( - gl: GL, - clipRadiusA: number, - clipRadiusB: number, - ): number[] { - const size = COVERAGE_VIEWPORT_SIZE; - const pixels = renderUprightCone( - gl, - PRIMITIVE_TEST_RADIUS, - PRIMITIVE_TEST_RADIUS, - clipRadiusA, - clipRadiusB, - ); - const fractionByRow: number[] = []; - for (let row = 0; row < size; ++row) { - for (let column = 0; column < size; ++column) { - const offset = (row * size + column) * 4; - if (pixels[offset + 1] !== 0) { - fractionByRow.push(pixels[offset]); - break; - } - } - } - return fractionByRow; - } - - // Covered pixels per row, from the endpoint A end to the endpoint B end. - function coneWidthByRow(gl: GL, radiusA: string, radiusB: string): number[] { - const size = COVERAGE_VIEWPORT_SIZE; - const pixels = renderUprightCone(gl, radiusA, radiusB, 0, 0); - const widthByRow: number[] = []; - for (let row = 0; row < size; ++row) { - let width = 0; - for (let column = 0; column < size; ++column) { - if (pixels[(row * size + column) * 4 + 1] !== 0) ++width; - } - if (width > 0) widthByRow.push(width); - } - return widthByRow; - } - - // A consumer mixes an attribute's two end values by this fraction. A constant - // would colour a whole edge from one endpoint. - it("reports where a cone hit falls between the endpoints", () => { + it("keeps a segment whose far endpoint has passed behind the camera", () => { webglTest((gl) => { - const fractionByRow = shadedConeAxialFractionByRow(gl, 0, 0); - expect(fractionByRow.length).toBeGreaterThan(8); - const [first, last] = [fractionByRow[0], fractionByRow.at(-1)!]; - expect(first).toBeLessThan(16); - expect(last).toBeGreaterThan(239); - for (let i = 1; i < fractionByRow.length; ++i) { - expect(fractionByRow[i]).toBeGreaterThanOrEqual(fractionByRow[i - 1]); - } + const endpointA: Point = [-0.3, -0.2, -1]; + const endpointB: Point = [0.5, 0.4, 1]; + // Endpoint B is behind the camera, so its own pixel radius is zero and it + // alone leaves nothing to draw. + expect( + coveredFraction( + drawCone( + gl, + { + endpointA, + endpointB, + radiusA: 0, + radiusB: `getRaycastRadiusForPixels(${glslPoint(endpointB)}, 1.0)`, + }, + SHADE_WHOLE_QUAD, + ), + ), + ).toBe(0); + + // The segment helper makes that end borrow the other's radius, which keeps + // the half that is still in view. + const radii = `getRaycastSegmentRadiiForPixels(${glslPoint(endpointA)}, ${glslPoint(endpointB)}, 1.0)`; + const borrowed = coveredFraction( + drawCone( + gl, + { + endpointA, + endpointB, + radiusA: `${radii}.x`, + radiusB: `${radii}.y`, + }, + SHADE_WHOLE_QUAD, + ), + ); + expect(borrowed).toBeGreaterThan(0.25); + expect(borrowed).toBeLessThan(1); }); }); - // Equal end radii must leave the taper rate at zero, so the quadratic collapses - // to the fixed-radius circle test. Drift would show as a width that changes along - // a cone that should not taper. - it("draws an exact cylinder when both end radii match", () => { + it("draws a constant width when both end radii match", () => { webglTest((gl) => { - const widthByRow = coneWidthByRow( - gl, - PRIMITIVE_TEST_RADIUS, - PRIMITIVE_TEST_RADIUS, + // Equal radii must leave the taper rate at zero, so the quadratic collapses + // to the fixed-radius circle test. Any drift shows as a width that changes + // along a cone that should not taper. + const widths = coveredWidthByRow( + drawCone( + gl, + { + endpointA: [0, -0.3, -1], + endpointB: [0, 0.3, -1], + radiusA: 0.05, + radiusB: 0.05, + }, + SHADE_SURFACE, + ), ); - expect(widthByRow.length).toBeGreaterThan(8); - const widest = Math.max(...widthByRow); - const narrowest = Math.min(...widthByRow); + expect(widths.length).toBeGreaterThan(8); // One pixel covers where the silhouette falls between sample points. - expect(widest - narrowest).toBeLessThanOrEqual(1); + expect(Math.max(...widths) - Math.min(...widths)).toBeLessThanOrEqual(1); }); }); - // Endpoint A is the lower end here, so the drawn width has to grow from bottom to - // top. The rows nearest each end are left out: the ends are open, so the rim - // projects as an ellipse and the silhouette closes over the last few rows. - it("tapers between two different end radii", () => { + it("tapers the width between two different end radii", () => { webglTest((gl) => { - // Wide enough that whole-pixel rasterisation does not dominate the ratio. - const widthByRow = coneWidthByRow(gl, "0.03", "0.12"); - expect(widthByRow.length).toBeGreaterThan(16); - const interior = widthByRow.slice( - Math.round(widthByRow.length * 0.15), - Math.round(widthByRow.length * 0.85), + // Endpoint A is the lower end, so the width grows from bottom to top. The + // radii are wide enough that whole-pixel rasterisation does not dominate. + const widths = coveredWidthByRow( + drawCone( + gl, + { + endpointA: [0, -0.3, -1], + endpointB: [0, 0.3, -1], + radiusA: 0.03, + radiusB: 0.12, + }, + SHADE_SURFACE, + ), + ); + expect(widths.length).toBeGreaterThan(16); + // The rows nearest each end are left out. The ends are open, so the rim + // there projects as an ellipse and the silhouette closes over them. + const interior = widths.slice( + Math.round(widths.length * 0.15), + Math.round(widths.length * 0.85), ); // Radius runs 0.0435 to 0.1065 across this slice, a ratio of 2.45. expect(interior.at(-1)! / interior[0]).toBeGreaterThan(1.8); @@ -351,105 +367,187 @@ describe("raycast primitives", () => { }); }); - // The whole chain, from a pixel radius through the per-end radii to the - // rasterised silhouette. - it("draws a segment at the requested pixel radius", () => { + it("draws a segment at the radius its pixel width asks for", () => { + webglTest((gl) => { + const endpointA: Point = [0, -0.3, -1]; + const endpointB: Point = [0, 0.3, -1]; + const radii = `getRaycastSegmentRadiiForPixels(${glslPoint(endpointA)}, ${glslPoint(endpointB)}, 6.0)`; + const widths = coveredWidthByRow( + drawCone( + gl, + { + endpointA, + endpointB, + radiusA: `${radii}.x`, + radiusB: `${radii}.y`, + }, + SHADE_SURFACE, + ), + ); + expect(widths.length).toBeGreaterThan(8); + // Both ends sit at the same depth, so both ask for 6 device pixels. That is + // a 12 pixel width, plus or minus a pixel of rasterisation. + expect(Math.max(...widths)).toBeGreaterThan(10); + expect(Math.max(...widths)).toBeLessThan(14); + }); + }); + + it("reports the axial fraction from 0 at endpoint A to 1 at endpoint B", () => { webglTest((gl) => { - const endpoints = "vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0)"; - const radii = `getRaycastSegmentRadiiForPixels(${endpoints}, 6.0)`; - const widthByRow = coneWidthByRow(gl, `${radii}.x`, `${radii}.y`); - expect(widthByRow.length).toBeGreaterThan(8); - // A radius of 6 device pixels is a 12 pixel width, plus or minus a pixel. - expect(Math.max(...widthByRow)).toBeGreaterThan(10); - expect(Math.max(...widthByRow)).toBeLessThan(14); + // A consumer mixes an attribute's two end values by this fraction, so a + // constant would colour a whole edge from one endpoint. + const fractions = axialFractionByRow( + drawCone( + gl, + { + endpointA: [0, -0.3, -1], + endpointB: [0, 0.3, -1], + radiusA: 0.05, + radiusB: 0.05, + }, + SHADE_AXIAL_FRACTION, + ), + ); + expect(fractions.length).toBeGreaterThan(8); + expect(fractions[0]).toBeLessThan(16); + expect(fractions.at(-1)!).toBeGreaterThan(239); + for (let i = 1; i < fractions.length; ++i) { + expect(fractions[i]).toBeGreaterThanOrEqual(fractions[i - 1]); + } }); }); - // The clip radius hands the region around a joint to the ball drawn there. The - // surface sits one radius from the axis, so a clip radius of 0.15 reaches - // sqrt(0.15^2 - 0.05^2) = 0.1414 along a 0.6 long axis. That is the lowest 23.6 - // percent of it. - it("clips the cone surface around an endpoint", () => { + it("clips the surface around an endpoint", () => { webglTest((gl) => { - const clipped = shadedConeAxialFractionByRow(gl, 0.15, 0); + // The clip radius hands the region around a joint to the ball drawn there. + // The surface sits one radius from the axis, so a clip radius of 0.15 + // reaches sqrt(0.15^2 - 0.05^2) = 0.1414 along a 0.6 long axis. That is the + // lowest 23.6 percent of it, or 60 as a 0 to 255 value. + const clipped = axialFractionByRow( + drawCone( + gl, + { + endpointA: [0, -0.3, -1], + endpointB: [0, 0.3, -1], + radiusA: 0.05, + radiusB: 0.05, + clipRadiusA: 0.15, + }, + SHADE_AXIAL_FRACTION, + ), + ); expect(clipped.length).toBeGreaterThan(8); - // 0.236 of the way along, as a 0-to-255 value, is 60. expect(clipped[0]).toBeGreaterThan(45); expect(clipped[0]).toBeLessThan(78); expect(clipped.at(-1)!).toBeGreaterThan(239); - // A clip radius under the cone radius cannot reach the surface at all. - const unreachable = shadedConeAxialFractionByRow(gl, 0.04, 0); - expect(unreachable).toEqual(shadedConeAxialFractionByRow(gl, 0, 0)); }); }); - // A radius of zero has no surface to hit, so shading its quad is pure waste. Both - // radius helpers return zero for a point at or behind the eye, so this runs every - // frame on any skeleton with geometry behind the camera. - it("culls a zero-radius primitive", () => { + it("leaves the surface alone when the clip radius cannot reach it", () => { webglTest((gl) => { + const spec: ConeSpec = { + endpointA: [0, -0.3, -1], + endpointB: [0, 0.3, -1], + radiusA: 0.05, + radiusB: 0.05, + }; + // A clip radius under the cone's own radius never reaches the surface, which + // is already 0.05 from the axis everywhere. expect( - measureQuadCoverage( - gl, - defineRaycastConeShader, - `emitRaycastCone(vec3(0.0, -0.3, -1.0), vec3(0.0, 0.3, -1.0), - 0.0, 0.0, 0.0, 0.0);`, + axialFractionByRow( + drawCone(gl, { ...spec, clipRadiusA: 0.04 }, SHADE_AXIAL_FRACTION), ), - ).toBe(0); - expect( - measureQuadCoverage( - gl, - defineRaycastSphereShader, - "emitRaycastSphere(vec3(0.0, 0.0, -1.0), 0.0);", - ), - ).toBe(0); + ).toEqual(axialFractionByRow(drawCone(gl, spec, SHADE_AXIAL_FRACTION))); }); }); +}); - // The bound is the exact silhouette conic, so the quad is the square around that - // ellipse and needs no margin. A radius of 0.05 one unit ahead has a silhouette - // 0.12086 in NDC, which is 3.87 pixels of a 64 pixel viewport, so the square is - // 59.8 pixels or 0.0146 of it. The disc itself is 0.0115. The projected box this - // replaced measured 0.0376, most of that its fixed two pixel margin. - it("bounds a sphere to its silhouette, and culls one behind the camera", () => { +describe("raycast sphere", () => { + it("publishes its depth and lighting to a consumer's own emit helper", () => { webglTest((gl) => { - const visible = sphereCoverage(gl, -1); - expect(visible).toBeGreaterThan(0.012); - expect(visible).toBeLessThan(0.017); - expect(sphereCoverage(gl, 1)).toBe(0); + const builder = new ShaderBuilder(gl); + builder.addOutputBuffer("vec4", "out_color", 0); + defineRaycastSphereShader(builder); + builder.setVertexMain("emitRaycastSphere(vec3(0.0, 0.0, -1.0), 0.2);"); + builder.addFragmentCode(` +void emitShaded() { + out_color = vec4(vec3(raycastLightingFactor), raycastSurfaceDepth); +} +`); + builder.setFragmentMain(`${glsl_raycastFragmentSetup}emitShaded();\n`); + builder.build().dispose(); }); }); - // The silhouette of a sphere of radius r at distance d has radius - // r / sqrt(d^2 - r^2), which for r of 0.2 at one unit is 4 percent more area than - // the r / d disc. So the shaded surface has to exceed that disc. Falling short of - // it is what a quad clipping the sphere would produce. - it("bounds a sphere without clipping its surface", () => { + it("bounds a sphere to the square around its silhouette", () => { webglTest((gl) => { - const shaded = measureShadedCoverage( + // A radius of 0.05 one unit ahead has a silhouette 0.12086 in NDC, which is + // 3.87 pixels of a 64 pixel viewport. The square around that disc is 59.8 + // pixels, or 0.0146 of the viewport. The disc itself is 0.0115. The + // projected box this replaced measured 0.0376, most of that a fixed two + // pixel margin the exact bound does not need. + const pixels = drawSphere( gl, - defineRaycastSphereShader, - "emitRaycastSphere(vec3(0.0, 0.0, -1.0), 0.2);", + { center: [0, 0, -1], radius: 0.05 }, + SHADE_WHOLE_QUAD, ); + expect(coveredFraction(pixels)).toBeGreaterThan(0.012); + expect(coveredFraction(pixels)).toBeLessThan(0.017); + }); + }); + + it("culls a sphere behind the camera", () => { + webglTest((gl) => { + const pixels = drawSphere( + gl, + { center: [0, 0, 1], radius: 0.05 }, + SHADE_WHOLE_QUAD, + ); + expect(coveredFraction(pixels)).toBe(0); + }); + }); + + it("culls a sphere with no radius", () => { + webglTest((gl) => { + const pixels = drawSphere( + gl, + { center: [0, 0, -1], radius: 0 }, + SHADE_WHOLE_QUAD, + ); + expect(coveredFraction(pixels)).toBe(0); + }); + }); + + it("bounds a sphere without clipping its surface", () => { + webglTest((gl) => { + // The silhouette of a sphere of radius r at distance d has radius + // r / sqrt(d^2 - r^2), which for r of 0.2 at one unit is 4 percent more area + // than the plain r / d disc. The conic gives that exactly, so the shaded + // surface has to exceed the plain disc. Falling short of it is what a quad + // clipping the sphere would produce. + // // A radius of 0.2 one unit ahead spans 15.5 pixels of a 64 pixel viewport, // so the r / d disc is 0.1831 of it. + const shaded = coveredFraction( + drawSphere(gl, { center: [0, 0, -1], radius: 0.2 }, SHADE_SURFACE), + ); expect(shaded).toBeGreaterThan(0.1831); expect(shaded).toBeLessThan(0.21); }); }); - // The conic is an ellipse only while the sphere clears the eye plane. Past that, - // part of the sphere projects arbitrarily far, so the whole viewport is the only - // honest bound. it("takes the whole viewport when the sphere crosses the eye plane", () => { webglTest((gl) => { - // Centered 0.3 ahead with a radius of 0.5, so the sphere spans the eye plane. - const coverage = measureQuadCoverage( + // Centred 0.3 ahead with a radius of 0.5, so the sphere spans the eye plane. + // The silhouette conic is an ellipse only while the sphere clears that + // plane. Past it, part of the sphere projects arbitrarily far, so the whole + // viewport is the only honest bound. + const pixels = drawSphere( gl, - defineRaycastSphereShader, - "emitRaycastSphere(vec3(0.0, 0.0, -0.3), 0.5);", + { center: [0, 0, -0.3], radius: 0.5 }, + SHADE_WHOLE_QUAD, ); - expect(coverage).toBe(1); + expect(coveredFraction(pixels)).toBe(1); }); }); }); diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index 5b677f79ae..bedf59e634 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -15,27 +15,30 @@ */ /** - * @file Shared GLSL for raycast primitives: a camera facing screen space quad - * whose fragment shader ray casts to find the 3D surface. - * Emits the depth and a lighting factor. + * @file Shared GLSL for raycast primitives. Each is a camera-facing screen space + * quad whose fragment shader casts a ray to find the 3D surface, then emits a depth + * and a lighting factor. * - * Positions, radii and normals are in raycast space, the space that `uProjection` - * maps to clip space. That space must reach clip space through a rotation, a - * uniform scale and the projection alone. Any anisotropic scale left in - * `uProjection` draws a sphere as an ellipsoid, because the intersection solves a - * true sphere in raycast space. `uLightDirection` is read in the same space, so - * the surface normal needs no further transform. + * Positions, radii and normals are in whatever space `uProjection` maps to clip + * space. That space must reach clip space through a rotation, a uniform scale and + * the projection alone. Any anisotropic scale left in `uProjection` draws a sphere + * as an ellipsoid, because the intersection solves a true sphere. `uLightDirection` + * is read in the same space, so the surface normal needs no further transform. + * `skeleton/frontend.ts` passes global coordinates scaled to canonical voxels. * - * The bound here suits an object with one long axis: a cone, a capsule, a cylinder. - * A primitive whose own silhouette has a closed form should bound itself in its own - * file instead, which is both tighter and less code. `raycast_sphere.ts` does. + * The file holds three things: the setup a primitive needs whatever its shape, the + * general algebra it solves with, and bounding boxes. One bounding box lives here + * so far, the axial OBB for an object with one long axis. An AABB would fit the + * same way, but nothing needs one. A primitive whose silhouette has a closed form + * should bound itself in its own file, which is both tighter and less code. See + * `raycast_sphere.ts`. */ import { mat4 } from "#src/util/geom.js"; import { glsl_getQuadVertexPosition } from "#src/webgl/quad.js"; import { - glsl_nearQuadraticRoot, - glsl_splitAlongDirection, + glsl_solveQuadratic, + glsl_splitAlongDir, } from "#src/webgl/raycast_shader_lib.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; import { glsl_clipLineToDepthRange } from "#src/webgl/shader_lib.js"; @@ -44,6 +47,11 @@ export function projectionMatrixShaderModule(builder: ShaderBuilder) { builder.addUniform("highp mat4", "uProjection"); } +// The fragment side of the contract. A primitive supplies +// `intersectRaycastPrimitive`, and gets the ray, the depth conversion and the +// lighting from here. `raycastSurfaceDepth` and `raycastLightingFactor` are file +// scope globals rather than locals of main, so that a consumer's own emit helper +// can read them. const glsl_raycastPrimitiveFragmentUtil = ` struct RaycastRay { highp vec3 origin; @@ -90,40 +98,53 @@ RaycastHit makeRaycastHit(highp vec3 surfacePoint, highp vec3 normal) { } `; -// The emitter below over-covers, so that a primitive straddling the near plane is -// never lost. +// Distance to the near and the far clip plane, both linear in position. A caller +// passes the maximum over its own shape: the value at the centre plus how far the +// shape reaches along each distance's own gradient. +// +// Negative form on the test, so a non-finite value fails open and leaves the shape +// drawn. An emitter over-covers for the same reason, so that a primitive straddling +// the near plane is never lost. const glsl_raycastDepthRangeCull = ` highp vec2 raycastDepthPlaneDistances(highp vec4 clip) { return vec2(clip.z + clip.w, clip.w - clip.z); } -// Both distances are linear, so callers pass the maximum over the shape: the base -// value plus how far the shape reaches along each distance's own gradient. Negative -// form, so a non-finite value fails open and leaves the shape drawn. bool raycastOutsideDepthRange(highp vec2 maxDepthDistances) { return maxDepthDistances.x < 0.0 || maxDepthDistances.y < 0.0; } `; +// RAYCAST_OFFSCREEN_NDC must exceed 1.0. Pinned exactly at the viewport edge, the +// margin an emitter adds would drag a fully off-screen primitive back on screen as +// a sliver. +// +// RAYCAST_MIN_RELATIVE_W is the smallest clip w a projected point may be treated as +// having, as a fraction of the local w scale. Relative, so it holds whatever units +// the projection works in. +// +// RAYCAST_MIN_AXIS_W_MARGIN is the nearest clip w an axis may keep, as a multiple of +// the depth its radial half-extents span. The margin over 1.0 is what a corner keeps +// in front of the eye, and so what caps how far outside the viewport it can project. const glsl_raycastQuadConstants = ` -// Must exceed 1.0. Pinned exactly at the viewport edge, the margin an emitter adds -// would drag a fully off-screen primitive back on screen as a sliver. const highp float RAYCAST_OFFSCREEN_NDC = 2.0; -// Smallest clip w a projected point may be treated as having, as a fraction of the -// local w scale. Relative, so it holds whatever units the projection works in. - const highp float RAYCAST_MIN_RELATIVE_W = 1e-4; -// Nearest clip w an axis may keep, as a multiple of the depth that the radial -// half-extents span. The margin over 1.0 is what a corner keeps in front of the -// eye, and so what caps how far outside the viewport a corner can project. const highp float RAYCAST_MIN_AXIS_W_MARGIN = 1.25; `; +// A screen space quad oriented along the projected axis, covering the box about the +// segment endpointA..endpointB with radial half-extents radiusVectorA and B. +// // Depth-clipping the segment first is what makes an oriented quad possible. A // primitive crossing the eye plane has an unbounded footprint, and clipping leaves -// every corner in front of the eye where the projected-corner hull is a valid bound. -// The radial half-extents reach nearer than the axis does, so the near end is -// trimmed again by their own depth. The part dropped there wraps the eye, and no -// quad of bounded size covers it. +// every corner in front of the eye, where the hull of the projected corners is a +// valid bound. The radial half-extents reach nearer than the axis does, so the near +// end is trimmed again by their own depth. The part dropped there wraps the eye, and +// no quad of bounded size covers it. +// +// `clipLineToDepthRange` rewrites clipA and clipB in place. Everything after it uses +// the clipped segment. Its result is tested in positive form, so a non-finite value +// culls rather than proceeding, and the equal depth case trims nothing, which leaves +// the corner test to reject it. const glsl_raycastAxialObbQuad = ` highp vec2 raycastClipToPixels(highp vec4 clip) { return clip.xy / clip.w * uViewportSize * 0.5; @@ -144,7 +165,6 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, return; } - // Clips clipA and clipB in place, so everything below uses the clipped segment. bool clipped = clipLineToDepthRange(clipA, clipB); // w runs linearly along the axis, so one crossing bounds the near end. @@ -162,8 +182,6 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, highp vec4 axisA = mix(clipA, clipB, startT); highp vec4 axisB = mix(clipA, clipB, endT); - // Positive form, so a non-finite result culls rather than proceeding. The equal - // depth case trims nothing, so the corner test still has to reject it. if (!(clipped && startT < endT && min(axisA.w, axisB.w) >= radialW)) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; @@ -173,12 +191,12 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, highp vec2 pixelsB = raycastClipToPixels(axisB); highp vec2 axisPixels = pixelsB - pixelsA; highp float axisLengthPixels = length(axisPixels); - highp vec2 alongDirection = + highp vec2 alongDir = axisLengthPixels > 1e-3 ? axisPixels / axisLengthPixels : vec2(1.0, 0.0); - highp vec2 perpendicularDirection = vec2(-alongDirection.y, alongDirection.x); + highp vec2 perpDir = vec2(-alongDir.y, alongDir.x); highp vec2 pixelCenter = (pixelsA + pixelsB) * 0.5; highp float halfAlongPixels = 0.0; - highp float halfPerpendicularPixels = 0.0; + highp float halfPerpPixels = 0.0; highp float ndcNearZ = 1.0; for (int corner = 0; corner < 8; ++corner) { @@ -186,41 +204,35 @@ void emitRaycastAxialObbQuad(highp vec3 endpointA, highp vec3 endpointB, + ((corner & 2) == 0 ? -clipVectorA : clipVectorA) + ((corner & 4) == 0 ? -clipVectorB : clipVectorB); highp vec2 offset = raycastClipToPixels(clip) - pixelCenter; - halfAlongPixels = max(halfAlongPixels, abs(dot(offset, alongDirection))); - halfPerpendicularPixels = max(halfPerpendicularPixels, abs(dot(offset, perpendicularDirection))); + halfAlongPixels = max(halfAlongPixels, abs(dot(offset, alongDir))); + halfPerpPixels = max(halfPerpPixels, abs(dot(offset, perpDir))); ndcNearZ = min(ndcNearZ, clamp(clip.z / clip.w, -1.0, 1.0)); } // The corner bound is exact. One pixel covers numerical error. highp vec2 pixels = pixelCenter - + alongDirection * (quadCoefficient.x * (halfAlongPixels + 1.0)) - + perpendicularDirection * (quadCoefficient.y * (halfPerpendicularPixels + 1.0)); + + alongDir * (quadCoefficient.x * (halfAlongPixels + 1.0)) + + perpDir * (quadCoefficient.y * (halfPerpPixels + 1.0)); gl_Position = vec4(pixels * 2.0 / uViewportSize, ndcNearZ, 1.0); } `; -// Raycast-space radius projecting to `radiusInPixels` device px, measured on the +// The radius that projects to `radiusInPixels` device pixels, measured on the // vertical viewport extent. A primitive sized this way holds a constant on-screen // size as the camera moves. +// A point at or behind the eye has no on-screen size to match, and gets zero. +// +// Column 1 of uInvProjection is one NDC unit of y, so its length converts between +// the two. The space reaches the eye through a rotation and a uniform scale, so that +// length does not turn with the camera. const glsl_raycastPrimitivePixelRadius = ` highp float raycastRadiusFromClipW(highp float clipW, highp float radiusInPixels) { - // At or behind the eye there is no on-screen size to match. if (!(clipW > 0.0)) return 0.0; - // uInvProjection column 1 is one NDC unit of y in raycast space. The positive - // scalar factors straight out of the length. Raycast space reaches the eye - // through a rotation and a uniform scale, so the length does not turn with the - // camera. return length(uInvProjection[1].xyz) * (2.0 / uViewportSize.y) * clipW * radiusInPixels; } highp float getRaycastRadiusForPixels(highp vec3 point, highp float radiusInPixels) { return raycastRadiusFromClipW((uProjection * vec4(point, 1.0)).w, radiusInPixels); } -// x at endpointA, y at endpointB. Two radii rather than one, so that the segment -// holds a single on-screen width along its whole length. A single radius would -// draw the far end of a receding segment thinner than the near end. -// -// An endpoint at or behind the eye has no on-screen size, so it borrows the other -// end's radius. Both behind the eye leaves both zero, which the emitter culls. highp vec2 getRaycastSegmentRadiiForPixels( highp vec3 endpointA, highp vec3 endpointB, highp float radiusInPixels) { highp vec2 radii = vec2( @@ -232,11 +244,15 @@ highp vec2 getRaycastSegmentRadiiForPixels( } `; +// Runs a primitive's own `intersectRaycastPrimitive` and publishes the result. A +// consumer places this at the top of its fragment main, so that a miss discards +// before any of its own code runs. +// +// The depth range test is in positive form, so a non-finite depth fails closed +// rather than poisoning the order-independent transparency weight. export const glsl_raycastFragmentSetup = ` RaycastHit raycastHit = intersectRaycastPrimitive(); if (!raycastHit.hit) discard; -// Positive form, so a non-finite depth fails closed rather than poisoning the OIT -// weight. if (!(raycastHit.windowDepth >= 0.0 && raycastHit.windowDepth <= 1.0)) discard; gl_FragDepth = raycastHit.windowDepth; raycastSurfaceDepth = raycastHit.windowDepth; @@ -254,8 +270,8 @@ export function raycastPrimitiveCoreModule(builder: ShaderBuilder) { builder.addVertexCode(glsl_raycastQuadConstants); builder.addVertexCode(glsl_raycastPrimitivePixelRadius); builder.addFragmentCode(glsl_raycastPrimitiveFragmentUtil); - builder.addFragmentCode(glsl_splitAlongDirection); - builder.addFragmentCode(glsl_nearQuadraticRoot); + builder.addFragmentCode(glsl_splitAlongDir); + builder.addFragmentCode(glsl_solveQuadratic); } export function defineRaycastAxialObbQuad(builder: ShaderBuilder) { @@ -266,16 +282,16 @@ export function defineRaycastAxialObbQuad(builder: ShaderBuilder) { const tempInvProjection = mat4.create(); -// `raycastClip` maps raycast space to clip space. See the constraint on that -// space at the top of this file. +// `primitiveToClip` maps the space a primitive's positions are given in to clip +// space. See the constraint on that space at the top of this file. export function initializeRaycastPrimitiveShader( shader: ShaderProgram, - raycastClip: mat4, + primitiveToClip: mat4, projectionParameters: { width: number; height: number }, ) { const { gl } = shader; - gl.uniformMatrix4fv(shader.uniform("uProjection"), false, raycastClip); - mat4.invert(tempInvProjection, raycastClip); + gl.uniformMatrix4fv(shader.uniform("uProjection"), false, primitiveToClip); + mat4.invert(tempInvProjection, primitiveToClip); gl.uniformMatrix4fv( shader.uniform("uInvProjection"), false, diff --git a/src/webgl/raycast_shader_lib.ts b/src/webgl/raycast_shader_lib.ts index 7f6b638414..000522269f 100644 --- a/src/webgl/raycast_shader_lib.ts +++ b/src/webgl/raycast_shader_lib.ts @@ -17,7 +17,7 @@ /** * @file General GLSL algebra for ray casting against a quadric surface. * - * `nearQuadraticRoot`, and the way callers form the coefficients they pass it, are + * `solveQuadratic`, and the way callers form the coefficients they pass it, are * adapted from Inigo Quilez's sphere intersector * (https://iquilezles.org/articles/intersectors/), MIT licensed: * @@ -37,41 +37,50 @@ * subtracts two large numbers. */ -export const glsl_splitAlongDirection = ` +// Splits a vector into the part along a unit direction and the part across it. +export const glsl_splitAlongDir = ` struct VectorSplit { highp float parallelDist; - highp vec3 perpendicular; + highp vec3 perp; }; -VectorSplit splitAlongDirection(highp vec3 vectorToSplit, highp vec3 unitDirection) { +VectorSplit splitAlongDir(highp vec3 vectorToSplit, highp vec3 unitDir) { VectorSplit split; - split.parallelDist = dot(unitDirection, vectorToSplit); - split.perpendicular = vectorToSplit - split.parallelDist * unitDirection; + split.parallelDist = dot(unitDir, vectorToSplit); + split.perp = vectorToSplit - split.parallelDist * unitDir; return split; } `; -export const glsl_nearQuadraticRoot = ` -struct QuadraticNearRoot { - bool exists; - highp float value; +// Both roots of quadraticA * t^2 + 2 * quadraticB * t + quadraticC, for a +// quadraticA above zero. +// +// Note the 2. quadraticB is half the linear coefficient, which is the form a ray +// against a quadric produces and the one thing a caller cannot guess. +// +// The discriminant is the whole cost, and both roots share it, so returning both +// is barely more than returning one. Positive form on the discriminant test, so a +// NaN falls through to no roots. GLSL ES does not promise IEEE NaN comparison, so +// that is defence and not a guarantee. +export const glsl_solveQuadratic = ` +struct QuadraticRoots { + bool exist; + highp float nearRoot; + highp float farRoot; }; -// Smaller root of quadraticA * t^2 + 2 * quadraticB * t + quadraticC, for a -// quadraticA above zero. Note the 2: quadraticB is half the linear coefficient. -QuadraticNearRoot nearQuadraticRoot(highp float quadraticA, highp float quadraticB, - highp float quadraticC) { +QuadraticRoots solveQuadratic(highp float quadraticA, highp float quadraticB, + highp float quadraticC) { highp float discriminant = quadraticB * quadraticB - quadraticA * quadraticC; - QuadraticNearRoot root; - // Positive form, so a NaN falls through to no root. GLSL ES does not promise - // IEEE NaN comparison, so this is defence and not a guarantee. - if (!(discriminant >= 0.0)) { - root.exists = false; - root.value = 0.0; - return root; - } - root.exists = true; - root.value = (-quadraticB - sqrt(discriminant)) / quadraticA; - return root; + QuadraticRoots roots; + roots.exist = false; + roots.nearRoot = 0.0; + roots.farRoot = 0.0; + if (!(discriminant >= 0.0)) return roots; + highp float rootOffset = sqrt(discriminant); + roots.exist = true; + roots.nearRoot = (-quadraticB - rootOffset) / quadraticA; + roots.farRoot = (-quadraticB + rootOffset) / quadraticA; + return roots; } `; diff --git a/src/webgl/raycast_sphere.ts b/src/webgl/raycast_sphere.ts index f9634c9d09..a261396b2a 100644 --- a/src/webgl/raycast_sphere.ts +++ b/src/webgl/raycast_sphere.ts @@ -31,22 +31,25 @@ * notice and this permission notice shall be included in all copies or * substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS". * - * `nearQuadraticRoot` in `raycast_shader_lib.ts` records how the intersection - * differs from the original. + * `solveQuadratic` in `raycast_shader_lib.ts` records how the intersection differs + * from the original. */ import { raycastPrimitiveCoreModule } from "#src/webgl/raycast_primitive.js"; import type { ShaderBuilder } from "#src/webgl/shader.js"; -export function defineRaycastSphereShader(builder: ShaderBuilder) { - builder.require(raycastPrimitiveCoreModule); - // xyz: center, w: radius. - builder.addVarying("highp vec4", "vSphere", "flat"); - builder.addVertexCode(` -// The screen-space silhouette of a sphere is a conic. Its clip-space form is the +// The screen space silhouette of a sphere is a conic. Its clip space form is the // dual quadric M * Q * transpose(M), for M the x, y and w rows of uProjection and Q // the dual of the sphere. The extent along an axis is the pair of roots of // conicWW * t^2 - 2 * conicCross * t + conicDiagonal. +// +// conicWW is positive exactly when the sphere clears the eye plane, which is when +// that conic is an ellipse. Otherwise part of the sphere projects arbitrarily far, +// and no quad short of the whole viewport covers it. +// +// The fragment shader writes gl_FragDepth and discards a depth outside the range, +// so the quad's own depth only has to survive clipping. Zero always does. +const glsl_emitRaycastSphere = ` void emitRaycastSphereQuad(highp vec3 center, highp float radius) { highp vec4 clipCenter = uProjection * vec4(center, 1.0); highp float radiusSq = radius * radius; @@ -55,7 +58,6 @@ void emitRaycastSphereQuad(highp vec3 center, highp float radius) { highp vec3 rowZ = vec3(uProjection[0].z, uProjection[1].z, uProjection[2].z); highp vec3 rowW = vec3(uProjection[0].w, uProjection[1].w, uProjection[2].w); - // Largest at the center plus the radius along each distance's own gradient. if (raycastOutsideDepthRange( raycastDepthPlaneDistances(clipCenter) + radius * vec2(length(rowZ + rowW), length(rowW - rowZ)))) { @@ -63,9 +65,6 @@ void emitRaycastSphereQuad(highp vec3 center, highp float radius) { return; } - // Positive exactly when the sphere clears the eye plane, which is when the conic - // is an ellipse. Otherwise part of the sphere projects arbitrarily far, and no - // quad short of the whole viewport covers it. highp float conicWW = clipCenter.w * clipCenter.w - radiusSq * dot(rowW, rowW); if (!(conicWW > 0.0)) { gl_Position = vec4(getQuadVertexPosition(vec2(-1.0), vec2(1.0)), 0.0, 1.0); @@ -91,12 +90,14 @@ void emitRaycastSphereQuad(highp vec3 center, highp float radius) { return; } - // The fragment shader writes gl_FragDepth and discards a depth outside the - // range, so the quad's own depth only has to survive clipping. Zero always does. + highp vec3 clipZGradient = + vec3(uProjection[0].z, uProjection[1].z, uProjection[2].z); + highp float ndcNearZ = clamp( + (clipCenter.z - radius * length(clipZGradient)) / clipCenter.w, -1.0, 1.0); gl_Position = vec4( clamp(getQuadVertexPosition(ndcMin, ndcMax), vec2(-RAYCAST_OFFSCREEN_NDC), vec2(RAYCAST_OFFSCREEN_NDC)), - 0.0, 1.0); + ndcNearZ, 1.0); } void emitRaycastSphere(highp vec3 center, highp float radius) { @@ -109,31 +110,38 @@ void emitRaycastSphere(highp vec3 center, highp float radius) { vSphere = vec4(center, radius); emitRaycastSphereQuad(center, radius); } -`); - builder.addFragmentCode(` +`; + +// Splitting along a unit ray direction leaves the leading coefficient one and the +// linear term zero, measured from the closest approach to the center. +// +// The near root is the one taken. The far one would fill the view when the camera +// clips inside. The normal is formed from two small terms, rather than a hit point +// far from the origin minus a center just as far from it. +const glsl_intersectRaycastSphere = ` RaycastHit intersectRaycastPrimitive() { RaycastRay ray = getRaycastRayThroughFragment(); highp float radius = vSphere.w; - // Splitting along a unit ray direction leaves the leading coefficient one and - // the linear term zero, measured from the closest approach to the center. - VectorSplit originSplit = - splitAlongDirection(ray.origin - vSphere.xyz, ray.direction); - highp float perpendicularDistSq = - dot(originSplit.perpendicular, originSplit.perpendicular); - QuadraticNearRoot root = - nearQuadraticRoot(1.0, 0.0, perpendicularDistSq - radius * radius); - if (!root.exists) return raycastMiss(); + VectorSplit originSplit = splitAlongDir(ray.origin - vSphere.xyz, ray.direction); + highp float perpDistSq = dot(originSplit.perp, originSplit.perp); + QuadraticRoots roots = + solveQuadratic(1.0, 0.0, perpDistSq - radius * radius); + if (!roots.exist) return raycastMiss(); - // The far crossing would fill the view when the camera clips inside. - highp float hitDist = -originSplit.parallelDist + root.value; + highp float hitDist = -originSplit.parallelDist + roots.nearRoot; if (!(hitDist >= 0.0)) return raycastMiss(); - // Two small terms, rather than a hit point far from the origin minus a center - // just as far from it. return makeRaycastHit( ray.origin + hitDist * ray.direction, - originSplit.perpendicular + root.value * ray.direction); + originSplit.perp + roots.nearRoot * ray.direction); } -`); +`; + +export function defineRaycastSphereShader(builder: ShaderBuilder) { + builder.require(raycastPrimitiveCoreModule); + // xyz: center, w: radius. + builder.addVarying("highp vec4", "vSphere", "flat"); + builder.addVertexCode(glsl_emitRaycastSphere); + builder.addFragmentCode(glsl_intersectRaycastSphere); } diff --git a/src/webgl/raycast_truncated_cone.ts b/src/webgl/raycast_truncated_cone.ts index 1c9e71ad63..64494d46c8 100644 --- a/src/webgl/raycast_truncated_cone.ts +++ b/src/webgl/raycast_truncated_cone.ts @@ -16,8 +16,7 @@ /** * @file Raycast truncated cone drawn on a camera-facing quad. Symbols below say - * cone for brevity. The surface is always the truncated one, and its ends are - * open. + * cone for brevity. The surface is always the truncated one, and its ends are open. * * The radius is given at each end and runs linearly between them, and equal radii * give an exact cylinder. A cone sized for a constant on-screen width needs the @@ -31,18 +30,16 @@ import { defineRaycastAxialObbQuad } from "#src/webgl/raycast_primitive.js"; import type { ShaderBuilder } from "#src/webgl/shader.js"; -export function defineRaycastConeShader(builder: ShaderBuilder) { - defineRaycastAxialObbQuad(builder); - builder.addVarying("highp vec3", "vConeEndpointA", "flat"); - // xyz: unit axis direction, w: axis length. - builder.addVarying("highp vec4", "vConeAxis", "flat"); - // xy: surface radius at endpoint A and at endpoint B. - // zw: clip radius at endpoint A and at endpoint B. - builder.addVarying("highp vec4", "vConeEndRadii", "flat"); - builder.addVertexCode(` +// Scaling the two radius vectors comes last. Scaling before the second cross +// product would normalise the zero vector when a radius is zero, which GLSL ES +// leaves undefined. The second product is already unit length, being the cross of +// two perpendicular unit vectors. +// +// The bound uses the wider of the two radii, which covers the whole surface. +const glsl_emitRaycastCone = ` void emitRaycastCone(highp vec3 endpointA, highp vec3 endpointB, - highp float radiusA, highp float radiusB, - highp float clipRadiusA, highp float clipRadiusB) { + highp float radiusA, highp float radiusB, + highp float clipRadiusA, highp float clipRadiusB) { highp float widestRadius = max(radiusA, radiusB); // A segment with both endpoints behind the eye is given zero radii, so this runs // every frame. Positive form, so a non-finite radius culls too. @@ -54,28 +51,23 @@ void emitRaycastCone(highp vec3 endpointA, highp vec3 endpointB, vConeEndRadii = vec4(radiusA, radiusB, clipRadiusA, clipRadiusB); highp vec3 axisVector = endpointB - endpointA; highp float axisLength = length(axisVector); - highp vec3 axisDirection = axisLength > 1e-6 ? axisVector / axisLength : vec3(0.0, 1.0, 0.0); - vConeAxis = vec4(axisDirection, axisLength); + highp vec3 axisDir = + axisLength > 1e-6 ? axisVector / axisLength : vec3(0.0, 1.0, 0.0); + vConeAxis = vec4(axisDir, axisLength); - // Scaling before the second cross product would normalise the zero vector when a - // radius is zero, which GLSL ES leaves undefined. highp vec3 offAxisVector = - abs(axisDirection.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0); - highp vec3 unitRadiusA = normalize(cross(offAxisVector, axisDirection)); - // Already unit length, being the cross product of two perpendicular unit vectors. - highp vec3 unitRadiusB = cross(axisDirection, unitRadiusA); + abs(axisDir.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0); + highp vec3 unitRadiusA = normalize(cross(offAxisVector, axisDir)); + highp vec3 unitRadiusB = cross(axisDir, unitRadiusA); emitRaycastAxialObbQuad(endpointA, endpointB, unitRadiusA * widestRadius, unitRadiusB * widestRadius); } -`); - builder.addFragmentCode(` -// 0.0 at endpoint A and 1.0 at endpoint B. Only meaningful once -// intersectRaycastPrimitive has returned a hit. -highp float raycastConeAxialFraction = 0.0; +`; // A surface point sits one local radius from the axis, so its distance to an // endpoint follows from the axial distance alone. +const glsl_coneEndClipped = ` bool coneEndClipped(highp float axialDist, highp float radiusAtHit) { highp float axialDistFromB = axialDist - vConeAxis.w; highp float radiusSq = radiusAtHit * radiusAtHit; @@ -84,68 +76,79 @@ bool coneEndClipped(highp float axialDist, highp float radiusAtHit) { axialDistFromB * axialDistFromB + radiusSq < vConeEndRadii.w * vConeEndRadii.w; } +`; // Across the axis the cone is a circle whose radius grows along the axis, so the // in-plane test is a quadratic rather than a fixed-radius circle. +// +// quadraticA is zero for a ray along the axis, which never meets the surface, and +// negative for a ray inside the taper angle, where the near root lies past the +// apex. Above zero it also puts perpSpeedSq there, which guards the divides after +// it. +// +// The quadratic is measured from the ray's closest approach to the axis, so that +// its constant term is a difference of two small numbers. The near root is the one +// taken. The far one would fill the view from inside. +// +// The interval test also holds the radius between the two end radii, so a surface +// past a cone apex never draws. The normal is the gradient of the surface equation, +// whose axial term is what the taper adds. +const glsl_intersectRaycastCone = ` +highp float raycastConeAxialFraction = 0.0; + RaycastHit intersectRaycastPrimitive() { RaycastRay ray = getRaycastRayThroughFragment(); - highp vec3 axisDirection = vConeAxis.xyz; + highp vec3 axisDir = vConeAxis.xyz; highp float axisLength = vConeAxis.w; highp float radiusA = vConeEndRadii.x; highp float inverseAxisLength = axisLength > 0.0 ? 1.0 / axisLength : 0.0; - // Radius added per unit along the axis. highp float taperRate = (vConeEndRadii.y - radiusA) * inverseAxisLength; - VectorSplit originSplit = - splitAlongDirection(ray.origin - vConeEndpointA, axisDirection); - VectorSplit directionSplit = splitAlongDirection(ray.direction, axisDirection); - highp float perpendicularSpeedSq = - dot(directionSplit.perpendicular, directionSplit.perpendicular); - // Radius added per unit along the ray. - highp float radiusRate = taperRate * directionSplit.parallelDist; + VectorSplit originSplit = splitAlongDir(ray.origin - vConeEndpointA, axisDir); + VectorSplit dirSplit = splitAlongDir(ray.direction, axisDir); + highp float perpSpeedSq = dot(dirSplit.perp, dirSplit.perp); + highp float radiusRate = taperRate * dirSplit.parallelDist; - // Zero for a ray along the axis, which never meets the surface. Negative for a - // ray inside the taper angle, where the near crossing lies past the apex. Above - // zero it also puts perpendicularSpeedSq there, guarding the divides below. - highp float quadraticA = perpendicularSpeedSq - radiusRate * radiusRate; + highp float quadraticA = perpSpeedSq - radiusRate * radiusRate; if (!(quadraticA > 0.0)) return raycastMiss(); - // The quadratic below is measured from here, so that its constant term is a - // difference of two small numbers. - highp float closestDist = - -dot(originSplit.perpendicular, directionSplit.perpendicular) - / perpendicularSpeedSq; - highp vec3 perpendicularAtClosest = - originSplit.perpendicular + closestDist * directionSplit.perpendicular; + highp float closestDist = -dot(originSplit.perp, dirSplit.perp) / perpSpeedSq; + highp vec3 perpAtClosest = originSplit.perp + closestDist * dirSplit.perp; highp float radiusAtClosest = radiusA + taperRate * - (originSplit.parallelDist + closestDist * directionSplit.parallelDist); + (originSplit.parallelDist + closestDist * dirSplit.parallelDist); - QuadraticNearRoot root = nearQuadraticRoot( + QuadraticRoots roots = solveQuadratic( quadraticA, -radiusAtClosest * radiusRate, - dot(perpendicularAtClosest, perpendicularAtClosest) - - radiusAtClosest * radiusAtClosest); - if (!root.exists) return raycastMiss(); + dot(perpAtClosest, perpAtClosest) - radiusAtClosest * radiusAtClosest); + if (!roots.exist) return raycastMiss(); - // The near crossing. Taking the far one would fill the view from inside. - highp float hitDist = closestDist + root.value; + highp float hitDist = closestDist + roots.nearRoot; if (!(hitDist >= 0.0)) return raycastMiss(); - // The interval test also holds the radius between the two end radii, so a - // surface past a cone apex never draws. highp float axialDist = - originSplit.parallelDist + hitDist * directionSplit.parallelDist; + originSplit.parallelDist + hitDist * dirSplit.parallelDist; if (!(axialDist >= 0.0 && axialDist <= axisLength)) return raycastMiss(); highp float radiusAtHit = radiusA + taperRate * axialDist; if (coneEndClipped(axialDist, radiusAtHit)) return raycastMiss(); raycastConeAxialFraction = axialDist * inverseAxisLength; - // The gradient of the surface equation. The axial term is what the taper adds. - highp vec3 perpendicularAtHit = - originSplit.perpendicular + hitDist * directionSplit.perpendicular; + highp vec3 perpAtHit = originSplit.perp + hitDist * dirSplit.perp; return makeRaycastHit( ray.origin + hitDist * ray.direction, - perpendicularAtHit - radiusAtHit * taperRate * axisDirection); + perpAtHit - radiusAtHit * taperRate * axisDir); } -`); +`; + +export function defineRaycastConeShader(builder: ShaderBuilder) { + defineRaycastAxialObbQuad(builder); + builder.addVarying("highp vec3", "vConeEndpointA", "flat"); + // xyz: unit axis direction, w: axis length. + builder.addVarying("highp vec4", "vConeAxis", "flat"); + // xy: surface radius at endpoint A and at endpoint B. + // zw: clip radius at endpoint A and at endpoint B. + builder.addVarying("highp vec4", "vConeEndRadii", "flat"); + builder.addVertexCode(glsl_emitRaycastCone); + builder.addFragmentCode(glsl_coneEndClipped); + builder.addFragmentCode(glsl_intersectRaycastCone); } From 661c28b5ad3dd0f051a89fcf7b2bf58f9cfb693d Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 2 Sep 2026 10:48:25 +0200 Subject: [PATCH 30/33] test: clarify tests --- src/webgl/lines.browser_test.ts | 8 +- src/webgl/raycast_primitive.browser_test.ts | 129 +++++++++----------- 2 files changed, 62 insertions(+), 75 deletions(-) diff --git a/src/webgl/lines.browser_test.ts b/src/webgl/lines.browser_test.ts index a92e36b88e..6b58a8111b 100644 --- a/src/webgl/lines.browser_test.ts +++ b/src/webgl/lines.browser_test.ts @@ -117,11 +117,9 @@ describe("line endpoint clipping", () => { it("measures the disc from the endpoints as given, not the clipped ends", () => { webglTest((gl) => { - // z runs -3 to 3, so only the middle third of the line survives the depth - // range. Measuring the disc from those moved ends would eat the drawn line - // where no node exists, the node itself having been clipped away with the - // rest of the segment. Both given endpoints end up more than one clip radius - // clear of what is drawn, so the discs must remove nothing. + // z runs -3 to 3, so only the middle third survives the depth range. Both + // given endpoints end up over one clip radius clear of what is drawn, so the + // discs must remove nothing. const spec = { endpointA: [-1, 0, -3, 1], endpointB: [1, 0, 3, 1], diff --git a/src/webgl/raycast_primitive.browser_test.ts b/src/webgl/raycast_primitive.browser_test.ts index 914e3b988d..7455dedf3e 100644 --- a/src/webgl/raycast_primitive.browser_test.ts +++ b/src/webgl/raycast_primitive.browser_test.ts @@ -139,6 +139,21 @@ function drawSphere( ); } +function buildWithEmitHelper( + gl: GL, + definePrimitive: (builder: ShaderBuilder) => void, + emitPrimitive: string, + emitHelperBody: string, +): void { + const builder = new ShaderBuilder(gl); + builder.addOutputBuffer("vec4", "out_color", 0); + definePrimitive(builder); + builder.setVertexMain(emitPrimitive); + builder.addFragmentCode(`void emitShaded() {\n${emitHelperBody}}\n`); + builder.setFragmentMain(`${glsl_raycastFragmentSetup}emitShaded();\n`); + builder.build().dispose(); +} + function coveredFraction(pixels: Uint8Array): number { let covered = 0; for (let i = 0; i < VIEWPORT * VIEWPORT; ++i) { @@ -176,30 +191,24 @@ function axialFractionByRow(pixels: Uint8Array): number[] { } describe("raycast cone", () => { - it("publishes its depth and lighting to a consumer's own emit helper", () => { + it("exposes its depth, lighting and axial fraction to a helper function", () => { webglTest((gl) => { - const builder = new ShaderBuilder(gl); - builder.addOutputBuffer("vec4", "out_color", 0); - defineRaycastConeShader(builder); - builder.setVertexMain( - `emitRaycastCone(vec3(0.0), vec3(0.0, 1.0, 0.0), 0.1, 0.2, 0.0, 0.0);`, - ); - // A helper sees the published globals, not main's locals. - builder.addFragmentCode(` -void emitShaded() { - out_color = vec4(raycastLightingFactor, raycastSurfaceDepth, - raycastConeAxialFraction, 1.0); -} -`); - builder.setFragmentMain(`${glsl_raycastFragmentSetup}emitShaded();\n`); - builder.build().dispose(); + expect(() => + buildWithEmitHelper( + gl, + defineRaycastConeShader, + "emitRaycastCone(vec3(0.0), vec3(0.0, 1.0, 0.0), 0.1, 0.2, 0.0, 0.0);", + ` out_color = vec4(raycastLightingFactor, raycastSurfaceDepth, + raycastConeAxialFraction, 1.0);\n`, + ), + ).not.toThrow(); }); }); it("bounds a visible cone to a small part of the viewport", () => { webglTest((gl) => { - // Upright, one unit ahead, 0.6 long and 0.1 across. Its silhouette is about - // 0.6 by 0.1 in raycast units, which is well under a tenth of the viewport. + // Upright, one unit ahead, 0.6 long and 0.1 across. That silhouette is well + // under a tenth of the viewport. const pixels = drawCone( gl, { @@ -234,10 +243,8 @@ void emitShaded() { it("culls a cone that wraps the camera", () => { webglTest((gl) => { - // The axis passes 0.2 in front of the camera and the radius is 0.5, so the - // camera is inside. That surface has no bounded screen footprint, and - // covering the viewport instead would shade every pixel of a depth-writing - // shader once per such edge. + // The axis passes 0.2 ahead and the radius is 0.5, so the camera is inside. + // That surface has no bounded screen footprint at all. const pixels = drawCone( gl, { @@ -254,9 +261,7 @@ void emitShaded() { it("culls a cone with no radius", () => { webglTest((gl) => { - // Both radius helpers return zero for a point at or behind the eye, so this - // runs every frame on a skeleton with geometry behind the camera. There is - // no surface to hit, so shading its quad would be pure waste. + // No radius, so no surface anywhere for a fragment to hit. const pixels = drawCone( gl, { @@ -275,8 +280,7 @@ void emitShaded() { webglTest((gl) => { const endpointA: Point = [-0.3, -0.2, -1]; const endpointB: Point = [0.5, 0.4, 1]; - // Endpoint B is behind the camera, so its own pixel radius is zero and it - // alone leaves nothing to draw. + // Endpoint B is behind the camera, so its own pixel radius is zero. expect( coveredFraction( drawCone( @@ -292,8 +296,7 @@ void emitShaded() { ), ).toBe(0); - // The segment helper makes that end borrow the other's radius, which keeps - // the half that is still in view. + // The segment helper makes it borrow endpoint A's radius instead. const radii = `getRaycastSegmentRadiiForPixels(${glslPoint(endpointA)}, ${glslPoint(endpointB)}, 1.0)`; const borrowed = coveredFraction( drawCone( @@ -314,9 +317,8 @@ void emitShaded() { it("draws a constant width when both end radii match", () => { webglTest((gl) => { - // Equal radii must leave the taper rate at zero, so the quadratic collapses - // to the fixed-radius circle test. Any drift shows as a width that changes - // along a cone that should not taper. + // Equal radii leave the taper rate at zero, so the quadratic collapses to + // the fixed-radius circle test and the width must not change. const widths = coveredWidthByRow( drawCone( gl, @@ -337,7 +339,7 @@ void emitShaded() { it("tapers the width between two different end radii", () => { webglTest((gl) => { - // Endpoint A is the lower end, so the width grows from bottom to top. The + // Endpoint A is the lower end, so the width grows from bottom to top. These // radii are wide enough that whole-pixel rasterisation does not dominate. const widths = coveredWidthByRow( drawCone( @@ -352,8 +354,8 @@ void emitShaded() { ), ); expect(widths.length).toBeGreaterThan(16); - // The rows nearest each end are left out. The ends are open, so the rim - // there projects as an ellipse and the silhouette closes over them. + // The ends are open, so the rim projects as an ellipse and closes the + // silhouette over the last few rows. Leave those out. const interior = widths.slice( Math.round(widths.length * 0.15), Math.round(widths.length * 0.85), @@ -394,8 +396,7 @@ void emitShaded() { it("reports the axial fraction from 0 at endpoint A to 1 at endpoint B", () => { webglTest((gl) => { - // A consumer mixes an attribute's two end values by this fraction, so a - // constant would colour a whole edge from one endpoint. + // Endpoint A is the lower end, so the fraction rises with the row. const fractions = axialFractionByRow( drawCone( gl, @@ -419,10 +420,9 @@ void emitShaded() { it("clips the surface around an endpoint", () => { webglTest((gl) => { - // The clip radius hands the region around a joint to the ball drawn there. - // The surface sits one radius from the axis, so a clip radius of 0.15 - // reaches sqrt(0.15^2 - 0.05^2) = 0.1414 along a 0.6 long axis. That is the - // lowest 23.6 percent of it, or 60 as a 0 to 255 value. + // The surface sits one radius from the axis, so a clip radius of 0.15 reaches + // sqrt(0.15^2 - 0.05^2) = 0.1414 along a 0.6 long axis. That is the lowest + // 23.6 percent of it, or 60 as a 0 to 255 value. const clipped = axialFractionByRow( drawCone( gl, @@ -451,8 +451,7 @@ void emitShaded() { radiusA: 0.05, radiusB: 0.05, }; - // A clip radius under the cone's own radius never reaches the surface, which - // is already 0.05 from the axis everywhere. + // A clip radius of 0.04 cannot reach a surface 0.05 from the axis. expect( axialFractionByRow( drawCone(gl, { ...spec, clipRadiusA: 0.04 }, SHADE_AXIAL_FRACTION), @@ -463,29 +462,24 @@ void emitShaded() { }); describe("raycast sphere", () => { - it("publishes its depth and lighting to a consumer's own emit helper", () => { + it("exposes its depth and lighting to a helper function", () => { webglTest((gl) => { - const builder = new ShaderBuilder(gl); - builder.addOutputBuffer("vec4", "out_color", 0); - defineRaycastSphereShader(builder); - builder.setVertexMain("emitRaycastSphere(vec3(0.0, 0.0, -1.0), 0.2);"); - builder.addFragmentCode(` -void emitShaded() { - out_color = vec4(vec3(raycastLightingFactor), raycastSurfaceDepth); -} -`); - builder.setFragmentMain(`${glsl_raycastFragmentSetup}emitShaded();\n`); - builder.build().dispose(); + expect(() => + buildWithEmitHelper( + gl, + defineRaycastSphereShader, + "emitRaycastSphere(vec3(0.0, 0.0, -1.0), 0.2);", + " out_color = vec4(vec3(raycastLightingFactor), raycastSurfaceDepth);\n", + ), + ).not.toThrow(); }); }); it("bounds a sphere to the square around its silhouette", () => { webglTest((gl) => { - // A radius of 0.05 one unit ahead has a silhouette 0.12086 in NDC, which is - // 3.87 pixels of a 64 pixel viewport. The square around that disc is 59.8 - // pixels, or 0.0146 of the viewport. The disc itself is 0.0115. The - // projected box this replaced measured 0.0376, most of that a fixed two - // pixel margin the exact bound does not need. + // A radius of 0.05 one unit ahead has a silhouette 0.12086 in NDC, so 3.87 + // pixels of a 64 pixel viewport. The square around that disc is 59.8 pixels, + // or 0.0146 of the viewport, against 0.0115 for the disc itself. const pixels = drawSphere( gl, { center: [0, 0, -1], radius: 0.05 }, @@ -520,14 +514,10 @@ void emitShaded() { it("bounds a sphere without clipping its surface", () => { webglTest((gl) => { - // The silhouette of a sphere of radius r at distance d has radius - // r / sqrt(d^2 - r^2), which for r of 0.2 at one unit is 4 percent more area - // than the plain r / d disc. The conic gives that exactly, so the shaded - // surface has to exceed the plain disc. Falling short of it is what a quad - // clipping the sphere would produce. - // // A radius of 0.2 one unit ahead spans 15.5 pixels of a 64 pixel viewport, - // so the r / d disc is 0.1831 of it. + // so the plain r / d disc is 0.1831 of it. The true silhouette radius is + // r / sqrt(d^2 - r^2), which is 4 percent more area, so the shaded surface + // must exceed the plain disc rather than fall short of it. const shaded = coveredFraction( drawSphere(gl, { center: [0, 0, -1], radius: 0.2 }, SHADE_SURFACE), ); @@ -539,9 +529,8 @@ void emitShaded() { it("takes the whole viewport when the sphere crosses the eye plane", () => { webglTest((gl) => { // Centred 0.3 ahead with a radius of 0.5, so the sphere spans the eye plane. - // The silhouette conic is an ellipse only while the sphere clears that - // plane. Past it, part of the sphere projects arbitrarily far, so the whole - // viewport is the only honest bound. + // Past that plane the conic is no longer an ellipse and part of the sphere + // projects arbitrarily far. const pixels = drawSphere( gl, { center: [0, 0, -0.3], radius: 0.5 }, From 71478b0f390fab264afb6c0a5fbe2e2c5ffe8701 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 2 Sep 2026 11:15:57 +0200 Subject: [PATCH 31/33] fix: correct lighting and rendering instabilities at large depth ranges --- src/skeleton/frontend.ts | 19 ++++ src/webgl/raycast_primitive.browser_test.ts | 99 +++++++++++++++++++-- src/webgl/raycast_primitive.ts | 23 +++-- 3 files changed, 130 insertions(+), 11 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 5bf807937b..5a49ce5b6f 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -109,6 +109,7 @@ import { defineVertexId, VertexIdHelper } from "#src/webgl/vertex_id.js"; const tempModelClip = mat4.create(); const tempCanonicalVoxelClip = mat4.create(); const tempModelToCanonicalVoxel = mat4.create(); +const tempCameraOffset = vec3.create(); const tempCanonicalVoxelScaleMatrix = mat4.create(); const tempCanonicalVoxelScale = vec3.create(); const tempInverseCanonicalVoxelScale = vec3.create(); @@ -512,16 +513,34 @@ void emitDefault() { canonicalVoxelFactors[1], canonicalVoxelFactors[2], ); + // Camera-relative, so that both the geometry and the ray origin the fragment + // shader reconstructs sit near zero. Without it both carry the camera's own + // coordinates, and float32 cannot hold their difference finely enough: a + // dataset 1e4 canonical voxels from the origin loses the surface normal + // outright, which shows as dark patches and banding along a tube. + const { invViewMatrix } = projectionParameters; + const cameraOffset = vec3.set( + tempCameraOffset, + invViewMatrix[12] * canonicalVoxelScale[0], + invViewMatrix[13] * canonicalVoxelScale[1], + invViewMatrix[14] * canonicalVoxelScale[2], + ); const modelToCanonicalVoxel = mat4.multiply( tempModelToCanonicalVoxel, mat4.fromScaling(tempCanonicalVoxelScaleMatrix, canonicalVoxelScale), modelMatrix, ); + modelToCanonicalVoxel[12] -= cameraOffset[0]; + modelToCanonicalVoxel[13] -= cameraOffset[1]; + modelToCanonicalVoxel[14] -= cameraOffset[2]; const canonicalVoxelClip = mat4.scale( tempCanonicalVoxelClip, projectionParameters.viewProjectionMat, vec3.inverse(tempInverseCanonicalVoxelScale, canonicalVoxelScale), ); + // Puts the offset back, so the pair still maps model coordinates to the same + // clip coordinates as before. + mat4.translate(canonicalVoxelClip, canonicalVoxelClip, cameraOffset); gl.uniformMatrix4fv( shader.uniform("uModelToCanonicalVoxel"), false, diff --git a/src/webgl/raycast_primitive.browser_test.ts b/src/webgl/raycast_primitive.browser_test.ts index 7455dedf3e..236fb820a6 100644 --- a/src/webgl/raycast_primitive.browser_test.ts +++ b/src/webgl/raycast_primitive.browser_test.ts @@ -40,6 +40,8 @@ const SHADE_WHOLE_QUAD = "out_color = vec4(1.0);\n"; // Runs the real hit test first, so a miss discards and the result measures the // primitive's own surface. const SHADE_SURFACE = `${glsl_raycastFragmentSetup}out_color = vec4(1.0);\n`; +// The lighting factor, which reads the surface normal. +const SHADE_LIGHTING = `${glsl_raycastFragmentSetup}out_color = vec4(vec3(raycastLightingFactor), 1.0);\n`; // Red carries the axial fraction. Green marks a fragment that survived the hit // test, since a fraction of zero is indistinguishable from an unwritten pixel. const SHADE_AXIAL_FRACTION = `${glsl_raycastFragmentSetup}out_color = vec4(raycastConeAxialFraction, 1.0, 0.0, 1.0);\n`; @@ -71,11 +73,38 @@ function glslRadius(radius: Radius): string { return typeof radius === "number" ? radius.toFixed(5) : radius; } +function cameraAtOrigin(): mat4 { + return mat4.perspective(mat4.create(), FIELD_OF_VIEW, 1, 0.1, 20); +} + +// As perspective_view/panel.ts builds it: the near bound clamps at 0.1 while the +// far bound grows with the depth range, and the geometry sits at a depth of 1. +function depthRange(relativeDepthRange: number): mat4 { + const range = relativeDepthRange / (1 / Math.tan(FIELD_OF_VIEW / 2)); + return mat4.perspective( + mat4.create(), + FIELD_OF_VIEW, + 1, + Math.max(0.1, 1 - range), + 1 + range, + ); +} + +// The same view, with the space's origin moved away from the camera by `offset`. +// A consumer subtracts the offset from its geometry, so the pair still lands the +// same pixels while the coordinates the fragment shader works in grow. +function cameraOffsetFromOrigin(offset: number): mat4 { + const projection = cameraAtOrigin(); + mat4.translate(projection, projection, [-offset, -offset, -offset]); + return mat4.translate(projection, projection, [offset, offset, offset]); +} + function render( gl: GL, definePrimitive: (builder: ShaderBuilder) => void, emitPrimitive: string, fragmentMain: string, + projection: mat4 = cameraAtOrigin(), ): Uint8Array { const builder = new ShaderBuilder(gl); builder.addOutputBuffer("vec4", "out_color", 0); @@ -88,11 +117,10 @@ function render( try { shader.bind(); vertexIdHelper.enable(); - initializeRaycastPrimitiveShader( - shader, - mat4.perspective(mat4.create(), FIELD_OF_VIEW, 1, 0.1, 20), - { width: VIEWPORT, height: VIEWPORT }, - ); + initializeRaycastPrimitiveShader(shader, projection, { + width: VIEWPORT, + height: VIEWPORT, + }); gl.viewport(0, 0, VIEWPORT, VIEWPORT); gl.clearColor(0, 0, 0, 0); gl.clear(WebGL2RenderingContext.COLOR_BUFFER_BIT); @@ -114,7 +142,12 @@ function render( } } -function drawCone(gl: GL, spec: ConeSpec, fragmentMain: string): Uint8Array { +function drawCone( + gl: GL, + spec: ConeSpec, + fragmentMain: string, + projection?: mat4, +): Uint8Array { const { clipRadiusA = 0, clipRadiusB = 0 } = spec; return render( gl, @@ -123,6 +156,7 @@ function drawCone(gl: GL, spec: ConeSpec, fragmentMain: string): Uint8Array { ${glslRadius(spec.radiusA)}, ${glslRadius(spec.radiusB)}, ${glslRadius(clipRadiusA)}, ${glslRadius(clipRadiusB)});`, fragmentMain, + projection, ); } @@ -154,6 +188,14 @@ function buildWithEmitHelper( builder.build().dispose(); } +function largestPixelDifference(a: Uint8Array, b: Uint8Array): number { + let largest = 0; + for (let i = 0; i < VIEWPORT * VIEWPORT; ++i) { + largest = Math.max(largest, Math.abs(a[i * 4] - b[i * 4])); + } + return largest; +} + function coveredFraction(pixels: Uint8Array): number { let covered = 0; for (let i = 0; i < VIEWPORT * VIEWPORT; ++i) { @@ -443,6 +485,51 @@ describe("raycast cone", () => { }); }); + it("survives a depth range wide enough to flatten the projection", () => { + webglTest((gl) => { + // The ray direction used to come from the far plane. Past a far of about + // 4e6 the projection's z coefficient rounds to exactly -1 in float32, the + // far plane stops being reachable through the inverse, and the direction + // came out a zero vector. Lines never hit this because they only ever + // project forward, and never invert. + const spec: ConeSpec = { + endpointA: [0, -0.3, -1], + endpointB: [0, 0.3, -1], + radiusA: 0.05, + radiusB: 0.05, + }; + const shaded = (relativeDepthRange: number) => + coveredFraction( + drawCone(gl, spec, SHADE_SURFACE, depthRange(relativeDepthRange)), + ); + expect(shaded(1e10)).toBeGreaterThan(0); + expect(shaded(1e10)).toBeCloseTo(shaded(1), 3); + }); + }); + + it("keeps the surface normal with the space's origin 1e4 away", () => { + webglTest((gl) => { + // The fragment shader subtracts the cone's position from a ray origin it + // reconstructs in the same space, so both carry the space's origin. At 1e4 + // against a radius of 0.05, float32 destroys the normal outright. A consumer + // has to centre the space near the camera, and this is what that buys. + const spec: ConeSpec = { + endpointA: [0, -0.3, -1], + endpointB: [0, 0.3, -1], + radiusA: 0.05, + radiusB: 0.05, + }; + const atOrigin = drawCone(gl, spec, SHADE_LIGHTING); + const farFromOrigin = drawCone( + gl, + spec, + SHADE_LIGHTING, + cameraOffsetFromOrigin(1e4), + ); + expect(largestPixelDifference(atOrigin, farFromOrigin)).toBeLessThan(3); + }); + }); + it("leaves the surface alone when the clip radius cannot reach it", () => { webglTest((gl) => { const spec: ConeSpec = { diff --git a/src/webgl/raycast_primitive.ts b/src/webgl/raycast_primitive.ts index bedf59e634..d5efc41b85 100644 --- a/src/webgl/raycast_primitive.ts +++ b/src/webgl/raycast_primitive.ts @@ -26,6 +26,13 @@ * is read in the same space, so the surface normal needs no further transform. * `skeleton/frontend.ts` passes global coordinates scaled to canonical voxels. * + * That space must also be centred near the camera. The fragment shader + * reconstructs its ray origin there through `uInvProjection`, then subtracts a + * primitive's own position from it, so both carry whatever the space's origin is. + * A radius of a few units against coordinates of 1e4 leaves float32 nothing to + * work with, and the surface normal is destroyed rather than merely noisy. Since + * `mat4` is a Float32Array, the projection cannot help beyond roughly 1e4 either. + * * The file holds three things: the setup a primitive needs whatever its shape, the * general algebra it solves with, and bounding boxes. One bounding box lives here * so far, the axial OBB for an object with one long axis. An AABB would fit the @@ -47,6 +54,14 @@ export function projectionMatrixShaderModule(builder: ShaderBuilder) { builder.addUniform("highp mat4", "uProjection"); } +// The ray direction comes from the near plane and the middle of the depth range, +// not from the far plane. Once far over near is large enough, the projection's z +// coefficient rounds to exactly -1 in float32 and the far plane stops being +// reachable through uInvProjection, which leaves the direction a zero vector and +// discards every fragment. At a near of 0.1 that happens by a far of 4e6. The +// middle of the range stays a fixed distance behind the near plane whatever the +// far plane does. +// // The fragment side of the contract. A primitive supplies // `intersectRaycastPrimitive`, and gets the ray, the depth conversion and the // lighting from here. `raycastSurfaceDepth` and `raycastLightingFactor` are file @@ -69,12 +84,10 @@ highp float raycastLightingFactor = 1.0; RaycastRay getRaycastRayThroughFragment() { highp vec2 ndc = (gl_FragCoord.xy / uViewportSize) * 2.0 - 1.0; highp vec4 nearClip = uInvProjection * vec4(ndc, -1.0, 1.0); - highp vec4 farClip = uInvProjection * vec4(ndc, 1.0, 1.0); - highp vec3 nearPoint = nearClip.xyz / nearClip.w; - highp vec3 farPoint = farClip.xyz / farClip.w; + highp vec4 midClip = uInvProjection * vec4(ndc, 0.0, 1.0); RaycastRay ray; - ray.origin = nearPoint; - ray.direction = normalize(farPoint - nearPoint); + ray.origin = nearClip.xyz / nearClip.w; + ray.direction = normalize(midClip.xyz / midClip.w - ray.origin); return ray; } highp float getRaycastWindowDepth(highp vec3 point) { From d9b076707f58e1b7e220c8ad26e407e7401ee6cb Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 4 Sep 2026 19:44:36 +0200 Subject: [PATCH 32/33] fix: also gate line clipping on depth range --- src/webgl/lines.browser_test.ts | 27 +++++++++++++++++++-------- src/webgl/lines.ts | 5 +++-- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/webgl/lines.browser_test.ts b/src/webgl/lines.browser_test.ts index 6b58a8111b..b71e2bf182 100644 --- a/src/webgl/lines.browser_test.ts +++ b/src/webgl/lines.browser_test.ts @@ -29,6 +29,8 @@ import { defineVertexId, VertexIdHelper } from "#src/webgl/vertex_id.js"; // one outside the depth range without setting up a projection. const VIEWPORT = 64; +// Clip space x, y, z, w. A point is inside the depth range when the magnitude of z +// is at most w, so a w of 1 puts the range at -1 to 1. type ClipPoint = readonly [number, number, number, number]; interface LineSpec { @@ -115,21 +117,30 @@ describe("line endpoint clipping", () => { }); }); - it("measures the disc from the endpoints as given, not the clipped ends", () => { + it("clips at an endpoint inside the depth range but not at one outside it", () => { webglTest((gl) => { - // z runs -3 to 3, so only the middle third survives the depth range. Both - // given endpoints end up over one clip radius clear of what is drawn, so the - // discs must remove nothing. + // Endpoint A has z of -1.5, so it is outside the range and the GPU clips away + // the node quad there. Its disc must not apply. A sits at pixel 24 and the + // depth range trims the drawn line to start at pixel 32, so a 10 pixel radius + // would otherwise reach past pixel 33. + // + // Endpoint B has z of 0.5, is inside the range, and keeps its disc. It sits + // at pixel 56, which is the drawn end, so the disc clears pixel 55. + // + // Measuring either disc from the clipped end rather than the endpoint as + // given puts A's disc at pixel 32, which also takes pixel 33. const spec = { - endpointA: [-1, 0, -3, 1], - endpointB: [1, 0, 3, 1], + endpointA: [-0.25, 0, -1.5, 1], + endpointB: [0.75, 0, 0.5, 1], widthInPixels: 6, } as const; const unclipped = drawLine(gl, { ...spec, clipRadiusInPixels: 0 }); const clipped = drawLine(gl, { ...spec, clipRadiusInPixels: 10 }); - expect(coveredCount(unclipped)).toBeGreaterThan(0); - expect(coveredCount(clipped)).toBe(coveredCount(unclipped)); + expect(isCovered(unclipped, 33, VIEWPORT / 2)).toBe(true); + expect(isCovered(clipped, 33, VIEWPORT / 2)).toBe(true); + expect(isCovered(unclipped, 55, VIEWPORT / 2)).toBe(true); + expect(isCovered(clipped, 55, VIEWPORT / 2)).toBe(false); }); }); diff --git a/src/webgl/lines.ts b/src/webgl/lines.ts index 5113835933..671f32d017 100644 --- a/src/webgl/lines.ts +++ b/src/webgl/lines.ts @@ -76,8 +76,9 @@ export function defineLineShader( ${ endpointClipping ? `highp vec2 lineClipToWindow(vec4 clip) { - // Far off screen for a point at or behind the eye, which has no clip disc. - if (!(clip.w > 0.0)) return vec2(-1e6); + // Far off screen unless the endpoint is inside the depth range. A node is drawn + // there only if it is, and the disc exists to leave room for one. + if (!(clip.w > 0.0 && clip.z >= -clip.w && clip.z <= clip.w)) return vec2(-1e6); return (clip.xy / clip.w * 0.5 + 0.5) / uLineParams.xy; }` : "" From 4f56d6e8511b1a868767010fdb9cf093bbe6b37a Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 4 Sep 2026 20:42:52 +0200 Subject: [PATCH 33/33] fix: correct color emission was accidentally double multiply alpha also fixes the fact that the edge shader had no emitRGBA, which caused both shaders to fail so you'd see nothing. The node and edge shaders now run independently so if either compile it works In the future we should likely allow some ability in skeleton shaders to easily indicate that something is targetted at lines, and something else at nodes - but this PR is already very large and I don't want to blow out the scope too far. --- python/tests/skeleton_rendering_test.py | 91 ++++++++++++++- src/skeleton/frontend.ts | 141 +++++++++++++----------- src/webgl/lines.browser_test.ts | 22 +--- 3 files changed, 174 insertions(+), 80 deletions(-) diff --git a/python/tests/skeleton_rendering_test.py b/python/tests/skeleton_rendering_test.py index 48a626a570..ef99f4240c 100644 --- a/python/tests/skeleton_rendering_test.py +++ b/python/tests/skeleton_rendering_test.py @@ -34,6 +34,21 @@ } """ +DEFAULT_SHADER = """ +void main () { + emitDefault(); +} +""" + +# getLineAlpha belongs to the edge program alone, so the node program cannot +# compile this. +EDGE_ONLY_SHADER = """ +#uicontrol vec3 color color(default="white") +void main () { + emitRGB(color * getLineAlpha()); +} +""" + class SinglePointSkeletonSource(neuroglancer.skeleton.SkeletonSource): def __init__(self): @@ -61,7 +76,17 @@ def screenshot_pixels(webdriver, size): return webdriver.viewer.screenshot(size=[size, size]).screenshot.image_pixels -def render_skeleton(webdriver, source, *, layout, line_width, size, mode=None): +def render_skeleton( + webdriver, + source, + *, + layout, + line_width, + size, + mode=None, + shader=USER_SHADER, + object_alpha=None, +): """Draws one red skeleton on black and returns the screenshot pixels.""" with webdriver.viewer.txn() as s: s.dimensions = dimensions @@ -78,6 +103,11 @@ def render_skeleton(webdriver, source, *, layout, line_width, size, mode=None): name="a", layer=neuroglancer.SegmentationLayer(source=source, segments=[1]), ) + # Red either way: a user shader reads the control, the default shader reads + # the segment colour. + s.layers[0].segment_default_color = "#f00" + if object_alpha is not None: + s.layers[0].object_alpha = object_alpha rendering = s.layers[0].skeleton_rendering rendering.line_width2d = line_width rendering.line_width3d = line_width @@ -86,11 +116,19 @@ def render_skeleton(webdriver, source, *, layout, line_width, size, mode=None): rendering.mode3d = mode else: rendering.mode2d = mode - rendering.shader = USER_SHADER + rendering.shader = shader rendering.shader_controls["color"] = "#f00" return screenshot_pixels(webdriver, size) +def drawn_pixel_count(image): + return int((image[..., 0] != 0).sum()) + + +def brightest_red(image): + return int(image[..., 0].max()) + + def assert_solid_color(image, color): np.testing.assert_array_equal( image, np.tile(np.array(color, dtype=np.uint8), image.shape[:2] + (1,)) @@ -224,3 +262,52 @@ def test_skeleton_render_mode(webdriver): f"{plain_count} for {layout}/{plain}. Enlarging the nodes should cover " "strictly more" ) + + +# A half opaque line measured 128. +MIN_BRIGHTNESS_FOR_A_HALF_OPAQUE_LINE = 122 + + +def test_cylinder_default_shader_object_alpha(webdriver): + # The line is the reference because its emitDefault does not premultiply twice. + # A tube overlaps itself at a joint, so it can only read above the line. + def brightest(mode): + return brightest_red( + render_skeleton( + webdriver, + TwoNodeSkeletonSource(), + layout="3d", + line_width=10, + size=100, + mode=mode, + shader=DEFAULT_SHADER, + object_alpha=0.5, + ) + ) + + line = brightest("lines") + tube = brightest("cylinders") + assert line > MIN_BRIGHTNESS_FOR_A_HALF_OPAQUE_LINE, ( + f"the half opaque line reached only {line}, too dark to compare against" + ) + assert tube >= line, ( + f"the half opaque tube reached {tube} against {line} for a line of the same " + f"colour and opacity. About {line // 2} means the object alpha was likely applied " + "twice" + ) + + +def test_edge_only_user_shader_still_draws_edges(webdriver): + # The node program's fallback is that same source, so it ends up with no shader. + image = render_skeleton( + webdriver, + TwoNodeSkeletonSource(), + layout="xy", + line_width=10, + size=100, + shader=EDGE_ONLY_SHADER, + ) + assert drawn_pixel_count(image) > 200, ( + f"drew {drawn_pixel_count(image)} pixels, so a node shader that failed to " + "compile meant the edge pass never happened" + ) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 5a49ce5b6f..861e8bd519 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -119,6 +119,22 @@ const DEFAULT_FRAGMENT_MAIN = `void main() { } `; +// The colour of a raycast primitive, shared by the cone and the sphere. +// emitRGBA takes a colour from the user's shader, so it premultiplies by alpha. +// `emitDefault` reads `uColor`, which instead arrives premultiplied by alpha. +// raycastLightingFactor is to apply spotlight lighting and ambient lighting +// in a Lambertian shading model +const glsl_raycastSkeletonEmit = ` +void emitRGBA(vec4 color) { + emit(vec4(color.rgb * raycastLightingFactor * color.a, color.a), + raycastSurfaceDepth, uPickID); +} +void emitDefault() { + emit(vec4(uColor.rgb * raycastLightingFactor, uColor.a), + raycastSurfaceDepth, uPickID); +} +`; + export enum SkeletonRenderMode3d { LINES = 0, LINES_AND_POINTS = 1, @@ -195,6 +211,7 @@ class RenderHelper extends RefCounted { defineCommonShader(builder: ShaderBuilder) { defineVertexId(builder); builder.require(projectionMatrixShaderModule); + // Already premultiplied by the object alpha, by getObjectColor. builder.addUniform("highp vec4", "uColor"); builder.addUniform("highp uint", "uPickID"); this.defineAttributeAccess(builder); @@ -289,15 +306,7 @@ emitRaycastCone(canonicalVertexA, canonicalVertexB, edgeRadii.x, edgeRadii.y, getRaycastRadiusForPixels(canonicalVertexA, uNodeClipPixelRadius), getRaycastRadiusForPixels(canonicalVertexB, uNodeClipPixelRadius)); `; - builder.addFragmentCode(` -void emitRGB(vec3 color) { - emit(vec4(color * raycastLightingFactor * uColor.a, uColor.a), - raycastSurfaceDepth, uPickID); -} -void emitDefault() { - emitRGB(uColor.rgb); -} -`); + builder.addFragmentCode(glsl_raycastSkeletonEmit); } else { defineLineShader(builder, { endpointClipping: true }); builder.addUniform("highp float", "uLineWidth"); @@ -307,14 +316,22 @@ highp uint lineEndpointIndex = getLineEndpointIndex(); highp uint vertexIndex = aVertexIndex.x * (1u - lineEndpointIndex) + aVertexIndex.y * lineEndpointIndex; `; builder.addFragmentCode(` -void emitRGB(vec3 color) { - emit(vec4(color * uColor.a, uColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}), uPickID); +void emitRGBA(vec4 color) { + emit(vec4(color.rgb * color.a, color.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}), uPickID); } void emitDefault() { emit(vec4(uColor.rgb, uColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}), uPickID); } `); } + // TODO unlike nodes, emitRGB is modulated by the layer alpha + // and alike with nodes the code is included here to be consistent + // across cylinders and lines either way + builder.addFragmentCode(` +void emitRGB(vec3 color) { + emitRGBA(vec4(color, uColor.a)); +} +`); this.finalizeShaderBuilder( builder, shaderBuilderState, @@ -345,12 +362,7 @@ emitRaycastSphere( canonicalPosition, getRaycastRadiusForPixels(canonicalPosition, uNodePixelRadius)); `; - builder.addFragmentCode(` -void emitRGBA(vec4 color) { - emit(vec4(color.rgb * raycastLightingFactor * color.a, color.a), - raycastSurfaceDepth, uPickID); -} -`); + builder.addFragmentCode(glsl_raycastSkeletonEmit); } else { defineCircleShader(builder, /*crossSectionFade=*/ this.targetIsSliceView); builder.addUniform("highp float", "uNodeDiameter"); @@ -360,15 +372,18 @@ void emitRGBA(vec4 color) { vec4 borderColor = color; emit(getCircleColor(color, borderColor), uPickID); } +void emitDefault() { + emitRGBA(uColor); +} `); } + // TODO unsure if nodes are intentionally not modulated + // by the layer alpha, but either way, this + // code is here to be consistent across points and balls builder.addFragmentCode(` void emitRGB(vec3 color) { emitRGBA(vec4(color, 1.0)); } -void emitDefault() { - emitRGBA(uColor); -} `); this.finalizeShaderBuilder( builder, @@ -672,10 +687,11 @@ void emitDefault() { } } - endLayer(gl: GL, ...shaders: ShaderProgram[]) { + endLayer(gl: GL, ...shaders: (ShaderProgram | null)[]) { const { vertexAttributes } = this; const numAttributes = vertexAttributes.length; for (const shader of shaders) { + if (shader === null) continue; for (let i = 0; i < numAttributes; ++i) { const textureUnit = shader.textureUnit(vertexAttributeSamplerSymbols[i]) + @@ -872,54 +888,55 @@ export class SkeletonLayer extends RefCounted { edgeShaderResult; const { shader: nodeShader, parameters: nodeShaderParameters } = nodeShaderResult; - if (edgeShader === null || nodeShader === null) { - // Shader error, skip drawing. - return; - } + if (edgeShader === null && nodeShader === null) return; const { shaderControlState } = this.displayState.skeletonRenderingOptions; const { projectionParameters } = renderContext; this.collectVisibleSkeletons(layer, renderContext); - edgeShader.bind(); - renderHelper.beginLayer(gl, edgeShader, renderContext, modelMatrix); - setControlsInShader( - gl, - edgeShader, - shaderControlState, - edgeShaderParameters.parseResult, - ); - renderHelper.setEdgeSizeUniforms( - gl, - edgeShader, - projectionParameters, - lineWidth, - nodeDiameter, - ); - renderHelper.beginEdges(edgeShader); - this.drawPass(renderContext, renderHelper, edgeShader, (skeleton) => - renderHelper.drawEdges(gl, edgeShader, skeleton), - ); - renderHelper.endEdges(); + if (edgeShader !== null) { + edgeShader.bind(); + renderHelper.beginLayer(gl, edgeShader, renderContext, modelMatrix); + setControlsInShader( + gl, + edgeShader, + shaderControlState, + edgeShaderParameters.parseResult, + ); + renderHelper.setEdgeSizeUniforms( + gl, + edgeShader, + projectionParameters, + lineWidth, + nodeDiameter, + ); + renderHelper.beginEdges(edgeShader); + this.drawPass(renderContext, renderHelper, edgeShader, (skeleton) => + renderHelper.drawEdges(gl, edgeShader, skeleton), + ); + renderHelper.endEdges(); + } - nodeShader.bind(); - renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); - renderHelper.setNodeSizeUniforms( - gl, - nodeShader, - projectionParameters, - nodeDiameter, - ); - setControlsInShader( - gl, - nodeShader, - shaderControlState, - nodeShaderParameters.parseResult, - ); - this.drawPass(renderContext, renderHelper, nodeShader, (skeleton) => - renderHelper.drawNodes(gl, nodeShader, skeleton), - ); + if (nodeShader !== null) { + nodeShader.bind(); + renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); + renderHelper.setNodeSizeUniforms( + gl, + nodeShader, + projectionParameters, + nodeDiameter, + ); + setControlsInShader( + gl, + nodeShader, + shaderControlState, + nodeShaderParameters.parseResult, + ); + this.drawPass(renderContext, renderHelper, nodeShader, (skeleton) => + renderHelper.drawNodes(gl, nodeShader, skeleton), + ); + } renderHelper.endLayer(gl, edgeShader, nodeShader); } diff --git a/src/webgl/lines.browser_test.ts b/src/webgl/lines.browser_test.ts index b71e2bf182..550657a826 100644 --- a/src/webgl/lines.browser_test.ts +++ b/src/webgl/lines.browser_test.ts @@ -25,12 +25,10 @@ import { ShaderBuilder } from "#src/webgl/shader.js"; import { webglTest } from "#src/webgl/testing.js"; import { defineVertexId, VertexIdHelper } from "#src/webgl/vertex_id.js"; -// A 64 pixel square viewport. Endpoints are given in clip space, so a test can put -// one outside the depth range without setting up a projection. const VIEWPORT = 64; -// Clip space x, y, z, w. A point is inside the depth range when the magnitude of z -// is at most w, so a w of 1 puts the range at -1 to 1. +// Clip space x, y, z, w, so a test can place an endpoint outside the depth range +// without a projection. Inside means the magnitude of z is at most w. type ClipPoint = readonly [number, number, number, number]; interface LineSpec { @@ -100,8 +98,8 @@ function isCovered(pixels: Uint8Array, x: number, y: number): boolean { describe("line endpoint clipping", () => { it("removes a disc at each endpoint, so a node drawn there has room", () => { webglTest((gl) => { - // Horizontal across the middle, from NDC x of -0.5 to 0.5. Endpoint A lands - // a quarter across the viewport, at pixel 16. + // Endpoint A lands a quarter across + // (-0.5 on NDC range -1 to 1), so at pixel 16 const spec = { endpointA: [-0.5, 0, 0, 1], endpointB: [0.5, 0, 0, 1], @@ -119,16 +117,8 @@ describe("line endpoint clipping", () => { it("clips at an endpoint inside the depth range but not at one outside it", () => { webglTest((gl) => { - // Endpoint A has z of -1.5, so it is outside the range and the GPU clips away - // the node quad there. Its disc must not apply. A sits at pixel 24 and the - // depth range trims the drawn line to start at pixel 32, so a 10 pixel radius - // would otherwise reach past pixel 33. - // - // Endpoint B has z of 0.5, is inside the range, and keeps its disc. It sits - // at pixel 56, which is the drawn end, so the disc clears pixel 55. - // - // Measuring either disc from the clipped end rather than the endpoint as - // given puts A's disc at pixel 32, which also takes pixel 33. + // A is at pixel 24 with the line trimmed to start at 32, so a 10 pixel + // radius would reach 33. B is at pixel 56, the drawn end, so its disc takes 55. const spec = { endpointA: [-0.25, 0, -1.5, 1], endpointB: [0.75, 0, 0.5, 1],