Variable MESH_PICK_FRAGMENT_SHADERConst
MESH_PICK_FRAGMENT_SHADER: "\n precision highp float;\n\n \nfloat perspectiveNearFade(int isOrtho, float viewZ, float nearCull) {\n if (isOrtho == 1) return 1.0;\n if (viewZ >= 0.0) return 0.0;\n return smoothstep(nearCull, nearCull * 2.0, -viewZ);\n}\n\n\n flat in highp float vNodeId;\n flat in highp vec2 vElementId;\n in mediump float vAlpha;\n in highp float vViewZ;\n #ifdef LUXAR_MESH_PICK_BASE_COLOR_TEX\n // The pick pass samples the texture for its ALPHA ONLY — it has no colour\n // output. Not an optimisation that could be skipped: texture alpha multiplies\n // coverage in the visual shader, so an RGBA basemap's cutout holes exist on\n // screen. A pick pass that ignored the texture would leave those holes\n // pickable AND depth-occluding, which is exactly the visual/pick divergence\n // `syncMeshPickAppearance` exists to prevent.\n //\n // A define rather than the runtime uniform `uAlphaCutout` uses, because a\n // sampler has to be DECLARED and `has_texture` is a per-node constant: unlike\n // the blending mode, no layers-panel action can turn a texture on for a node\n // whose store had none.\n uniform sampler2D uBaseColorTex;\n in mediump vec2 vUv;\n #endif\n\n uniform mediump float uOpacity;\n uniform mediump float uAlphaCutoff;\n // Near-fade start, world units (scene-relative), mirroring the visual\n // material. The ortho test reads three's isOrthographic, not a uniform.\n uniform float uNearCull;\n // 1 = 'opaque': apply the visual shader's hard cutout. Runtime uniform, not a\n // define — a layers-panel mode switch must not recompile the pick program.\n uniform int uAlphaCutout;\n // 1 = the depth-ordered surface modes ('opaque'/'normal'): write real\n // projected depth (front-most wins). 0 = the commutative modes\n // (additive/luminous/max): brightness-as-depth (brightest wins), because real\n // surface depth there would let a dim mesh in front depth-occlude a brighter\n // node behind it, contradicting the brightest-wins vote.\n uniform int uSurfaceDepth;\n\n out vec4 fragColor;\n\n void main() {\n // Identical coverage to the visual shader (§6.2): a mesh has no per-element\n // intensity/amplitude, so coverage is the per-vertex alpha times node\n // opacity — NOT `intensity * uOpacity` like the emissive types.\n mediump float a = vAlpha * uOpacity;\n #ifdef LUXAR_MESH_PICK_BASE_COLOR_TEX\n // Alpha only, and NO luminance swizzle: a 1-channel texture samples alpha\n // 1.0, so the swizzle the visual shader needs for `.rgb` has no analogue\n // here. Multiplied in BEFORE the cutout comparison below, matching the\n // visual shader's ordering exactly — comparing a different quantity is how\n // the two passes would disagree about where the holes are.\n a *= texture(uBaseColorTex, vUv).a;\n #endif\n\n // Same fade, same 1e-20 degenerate-smoothstep floor and same 0.01 reject as\n // the visual shader — pick coverage must keep matching visible coverage as\n // the camera flies into the surface. Rejected before anything is written, so\n // a faded-out fragment contributes neither an id nor depth.\n // Ortho test from three's per-draw 'isOrthographic' (the camera being\n // drawn with), not a CPU-pushed flag; the fragment stage has no\n // projectionMatrix to read it from.\n float nearFade = perspectiveNearFade(isOrthographic ? 1 : 0, vViewZ, max(uNearCull, 1e-20));\n if (nearFade < 0.01) discard;\n\n if (uAlphaCutout == 1) {\n if (a < uAlphaCutoff) discard;\n // Survivors of the cutout are FULLY OPAQUE on screen — the visual shader\n // emits `vec4(rgb, 1.0)` for them — so the pick brightness must be 1.0\n // too. Carrying the pre-cutout `a` through instead would under-weight a\n // solid mesh in the cross-node brightness vote purely because its author\n // wrote 0.6 into a channel the visual output ignores.\n a = 1.0;\n }\n\n // The fade folds in HERE rather than into `a` above, so it reaches both\n // arms with one multiply: the cutout arm has already replaced `a` with the\n // constant 1.0 (its visual twin ramps the shaded RGB by the same factor),\n // and the commutative arm carries the coverage the visual twin faded.\n // Folding earlier would instead move the cutout comparison and dissolve the\n // holes open as the camera approached.\n mediump float brightness = clamp(a * nearFade, 0.0, 1.0);\n\n fragColor = vec4(vNodeId, vElementId.x, brightness, vElementId.y);\n // Pick depth convention, synced from the MAIN material's blending mode by\n // PickingSystem.renderPickBuffer(). Writing gl_FragDepth at all forfeits\n // early-z; the pick pass is half-resolution and the sibling gsplat pick\n // makes the same trade.\n gl_FragDepth = (uSurfaceDepth == 1) ? gl_FragCoord.z : 1.0 - brightness;\n }\n " = ...
Picking fragment stage for meshes.
Writes the shared RGBA32F pick encoding
vec4(nodeId, elementId-low16, brightness, elementId-high16).Why
vElementIdisflat, and which corner it reportsA
flatvarying is sourced from ONE corner of the triangle, and the two backends do not default to the same one: OpenGL ES 3.0 fixes the provoking vertex to the last vertex of the primitive, while the WGSL@interpolate(flat)three emits defaults to first-vertex sampling. The pick contract at vertex granularity is therefore "a corner vertex of the front-most triangle under the cursor" — the cursor is over the face, not a vertex, so every corner is an equally valid answer and no consumer may assume a specific one. The GLSL path still aligns with WebGPU where the platform allows (WEBGL_provoking_vertex, see./material.ts); where it does not, the divergence stands as a documented §6.4 exception and the parity tests assert membership in the expected face rather than one exact corner.Non-
flatwould be far worse than a corner ambiguity:gl_VertexIDdiffers at every corner of a shared-vertex indexed draw, so a linearly interpolatedvElementIdarrives FRACTIONAL and the readback'sMath.roundresolves to arbitrary unrelated vertices. (The point TSL pick escapes withoutflatonly because a single-instance quad's four corners carry the same id, making interpolation the identity — an accident that does not survive to mesh.)The cutout must match the visual shader exactly
Without the identical
a < uAlphaCutoffdiscard, a hole the user can see through would still rasterize here at true surface depth — becoming pickable AND depth-occluding picks of the nodes visible through it.The near fade is here for exactly the same reason, and is evaluated PER FRAGMENT to match the visual pair (a triangle spans depth). It folds into
brightness, the way the point and gsplat pick shaders fold theirs, so pick salience tracks visible salience; and a fragment below the 0.01 reject is discarded, so it writes neither the id nor depth.