Fix overlay shapes ignoring the z-buffer (bugreport 20260920-131610)
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sun, 20 Sep 2026 10:54:56 +0000 (13:54 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sun, 20 Sep 2026 10:54:56 +0000 (13:54 +0300)
Solid geometry never occluded Line or Billboard/GlowingPoint output:
their span writers blended pixels unconditionally ('overlay semantics'),
so wireframe shapes, text sprites and glow points showed through solid
surfaces — e.g. wireframe pyramids visible through the solid sphere in
ShapeGalleryDemo.

Lines: interpolate 1/z per pixel (projectively linear along the 2D
projected segment; thick path projects each span pixel onto the line
parameter, thin paths reuse their walk parameter) and depth-test with
the same idiom as SolidPolygon. Sprites: screen-aligned, so one 1/z
value covers the whole quad. Both depth-TEST only and never depth-
WRITE (like pass-2 translucent geometry): a line/sprite must not
occlude geometry painted after it, and the HiZ pyramid is unaffected.

Verified: bug-pose render (Snapshot.cameraFromPose on the bugreport
pose) shows a clean sphere disc; all 21 unaffected goldens bit-
identical; shape-gallery + stereo goldens re-blessed after visual
review confirmed every changed pixel is a correct occlusion (wireframe
lines/grid/particles blocked by the solid sphere). Engine 48/48.

src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/Billboard.java
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.java

index 77f08f5..fa40bdc 100644 (file)
@@ -95,13 +95,16 @@ public class Billboard extends AbstractCoordinateShape {
      */
     @Override
     public void paint(final RenderingContext targetRenderingArea) {
-        // Sprites don't participate in depth (overlay semantics) —
-        // paint only in the alpha pass.
+        // Sprites paint only in the alpha pass: they blend, depth-TEST
+        // against the opaque z-buffer (solid geometry occludes them),
+        // and never depth-WRITE. The sprite is screen-aligned, so 1/z
+        // is constant across the quad — one depth value for all pixels.
         if (targetRenderingArea.depthPass == 1)
             return;
 
         // distance from camera/viewer to center of the texture
         final double z = vertices.get(0).transformedCoordinate(targetRenderingArea).z;
+        final double zw = 1d / z;
 
         // compute forward oriented texture visible distance from center
         final double visibleHorizontalDistanceFromCenter = (targetRenderingArea.width
@@ -159,9 +162,11 @@ public class Billboard extends AbstractCoordinateShape {
             return;
 
         final int[] targetPixels = targetRenderingArea.pixels;
+        final float[] targetDepth = targetRenderingArea.depth;
         final int[] sourcePixels = textureBitmap.pixels;
         final int textureWidth = textureBitmap.width;
         final int textureHeight = textureBitmap.height;
+        final double depthMargin = RenderingContext.DEPTH_MARGIN_DZ * zw * zw;
         final int targetWidth = targetRenderingArea.width;
 
         // Fixed-point (16.16) texture stepping values - eliminates per-pixel division
@@ -186,6 +191,13 @@ public class Billboard extends AbstractCoordinateShape {
 
             for (int x = onScreenCappedXStart; x < onScreenCappedXEnd; x++) {
 
+                // depth-test (never write): solids occlude sprites
+                if (zw <= targetDepth[targetOffset] - depthMargin) {
+                    sourceX += sourceXStep;
+                    targetOffset++;
+                    continue;
+                }
+
                 // Convert fixed-point X to integer and compute source address
                 final int sourceAddress = scanlineBase + (sourceX >> 16);
 
index 7b1e7a4..a0478d6 100644 (file)
@@ -92,16 +92,30 @@ public class Line extends AbstractCoordinateShape {
     }
 
     /**
-     * Draws a horizontal scanline between two interpolators with alpha blending.
+     * Draws a thick line as a series of horizontal spans.
+     *
+     * <p>Each pixel is depth-tested against the z-buffer (lines
+     * participate in occlusion: solid geometry hides the parts of a
+     * line that lie behind it), but depth is never written — a line
+     * must not occlude geometry painted after it.</p>
      *
      * @param line1        the left edge interpolator
      * @param line2        the right edge interpolator
      * @param y            the Y coordinate of the scanline
      * @param renderBuffer the rendering context to draw into
+     * @param p1x          X of the line's first projected endpoint
+     * @param p1y          Y of the line's first projected endpoint
+     * @param dtDx         d(t)/dx of the 2D line parameter (xp / len²)
+     * @param dtDy         d(t)/dy of the 2D line parameter (yp / len²)
+     * @param zwP1         1/z at the first endpoint
+     * @param zwDelta      (1/z at second endpoint) - zwP1
      */
     private void drawHorizontalLine(final LineInterpolator line1,
                                     final LineInterpolator line2, final int y,
-                                    final RenderingContext renderBuffer) {
+                                    final RenderingContext renderBuffer,
+                                    final double p1x, final double p1y,
+                                    final double dtDx, final double dtDy,
+                                    final double zwP1, final double zwDelta) {
 
         int x1 = line1.getX(y);
         int x2 = line2.getX(y);
@@ -122,8 +136,16 @@ public class Line extends AbstractCoordinateShape {
         final int unclippedWidth = x2 - x1;
         final double dinc = (d2 - d1) / unclippedWidth;
 
+        // 1/z at the first (leftmost) span pixel: project the pixel onto
+        // the 2D line for its parameter t, then interpolate 1/z linearly
+        // (projectively correct along a projected 3D line). Computed
+        // after the endpoint swap so x1 is genuinely the smaller X.
+        double zw = zwP1 + ((((x1 - p1x) * dtDx) + ((y - p1y) * dtDy)) * zwDelta);
+        final double zwInc = dtDx * zwDelta;
+
         if (x1 < renderBuffer.renderMinX) {
             d1 += (dinc * (renderBuffer.renderMinX - x1));
+            zw += zwInc * (renderBuffer.renderMinX - x1);
             x1 = renderBuffer.renderMinX;
         }
 
@@ -136,6 +158,7 @@ public class Line extends AbstractCoordinateShape {
 
         int offset = (y * renderBuffer.width) + x1;
         final int[] pixels = renderBuffer.pixels;
+        final float[] depth = renderBuffer.depth;
 
         final int lineAlpha = color.a;
 
@@ -150,18 +173,23 @@ public class Line extends AbstractCoordinateShape {
             final int realLineAlpha = (int) (lineAlpha * alphaMultiplier);
             final int backgroundAlpha = 255 - realLineAlpha;
 
-            final int dest = pixels[offset];
-            final int destR = (dest >> 16) & 0xff;
-            final int destG = (dest >> 8) & 0xff;
-            final int destB = dest & 0xff;
+            // Depth-test like pass-2 translucent geometry: never write.
+            if (zw > depth[offset] - RenderingContext.DEPTH_MARGIN_DZ * zw * zw) {
+                final int dest = pixels[offset];
+                final int destR = (dest >> 16) & 0xff;
+                final int destG = (dest >> 8) & 0xff;
+                final int destB = dest & 0xff;
 
-            final int newR = ((destR * backgroundAlpha) + (colorR * realLineAlpha)) >> 8;
-            final int newG = ((destG * backgroundAlpha) + (colorG * realLineAlpha)) >> 8;
-            final int newB = ((destB * backgroundAlpha) + (colorB * realLineAlpha)) >> 8;
+                final int newR = ((destR * backgroundAlpha) + (colorR * realLineAlpha)) >> 8;
+                final int newG = ((destG * backgroundAlpha) + (colorG * realLineAlpha)) >> 8;
+                final int newB = ((destB * backgroundAlpha) + (colorB * realLineAlpha)) >> 8;
 
-            pixels[offset++] = (newR << 16) | (newG << 8) | newB;
+                pixels[offset] = (newR << 16) | (newG << 8) | newB;
+            }
 
+            offset++;
             d1 += dinc;
+            zw += zwInc;
         }
 
     }
@@ -176,12 +204,16 @@ public class Line extends AbstractCoordinateShape {
     private void drawSinglePixelHorizontalLine(final RenderingContext buffer,
                                                final int alpha,
                                                final Point2D onScreenPoint1,
-                                               final Point2D onScreenPoint2) {
+                                               final Point2D onScreenPoint2,
+                                               final double zwP1,
+                                               final double zwP2) {
         int xStart = (int) onScreenPoint1.x;
         int xEnd = (int) onScreenPoint2.x;
 
         int lineHeight;
         int yBase;
+        final double zwA;
+        final double zwB;
 
         if (xStart > xEnd) {
             final int tmp = xStart;
@@ -189,9 +221,14 @@ public class Line extends AbstractCoordinateShape {
             xEnd = tmp;
             lineHeight = (int) (onScreenPoint1.y - onScreenPoint2.y);
             yBase = (int) onScreenPoint2.y;
+            // walk runs from endpoint 2 to endpoint 1
+            zwA = zwP2;
+            zwB = zwP1;
         } else {
             yBase = (int) onScreenPoint1.y;
             lineHeight = (int) (onScreenPoint2.y - onScreenPoint1.y);
+            zwA = zwP1;
+            zwB = zwP2;
         }
 
         final int lineWidth = xEnd - xStart;
@@ -199,6 +236,7 @@ public class Line extends AbstractCoordinateShape {
             return;
 
         final int[] pixels = buffer.pixels;
+        final float[] depth = buffer.depth;
         final int backgroundAlpha = 255 - alpha;
 
         final int redWithAlpha = color.r * alpha;
@@ -215,16 +253,21 @@ public class Line extends AbstractCoordinateShape {
                     if ((y >= 0) && (y < buffer.height)) {
                         int offset = (y * buffer.width) + x;
 
-                        final int dest = pixels[offset];
-                        final int destR = (dest >> 16) & 0xff;
-                        final int destG = (dest >> 8) & 0xff;
-                        final int destB = dest & 0xff;
-
-                        final int newR = ((destR * backgroundAlpha) + redWithAlpha) >> 8;
-                        final int newG = ((destG * backgroundAlpha) + greenWithAlpha) >> 8;
-                        final int newB = ((destB * backgroundAlpha) + blueWithAlpha) >> 8;
-
-                        pixels[offset] = (newR << 16) | (newG << 8) | newB;
+                        // depth-test (never write): solids occlude lines
+                        final double zw = zwA
+                                + (((double) relativeX / lineWidth) * (zwB - zwA));
+                        if (zw > depth[offset] - RenderingContext.DEPTH_MARGIN_DZ * zw * zw) {
+                            final int dest = pixels[offset];
+                            final int destR = (dest >> 16) & 0xff;
+                            final int destG = (dest >> 8) & 0xff;
+                            final int destB = dest & 0xff;
+
+                            final int newR = ((destR * backgroundAlpha) + redWithAlpha) >> 8;
+                            final int newG = ((destG * backgroundAlpha) + greenWithAlpha) >> 8;
+                            final int newB = ((destB * backgroundAlpha) + blueWithAlpha) >> 8;
+
+                            pixels[offset] = (newR << 16) | (newG << 8) | newB;
+                        }
                     }
                 }
             }
@@ -242,12 +285,16 @@ public class Line extends AbstractCoordinateShape {
     private void drawSinglePixelVerticalLine(final RenderingContext buffer,
                                              final int alpha,
                                              final Point2D onScreenPoint1,
-                                             final Point2D onScreenPoint2) {
+                                             final Point2D onScreenPoint2,
+                                             final double zwP1,
+                                             final double zwP2) {
         int yStart = (int) onScreenPoint1.y;
         int yEnd = (int) onScreenPoint2.y;
 
         int lineWidth;
         int xBase;
+        final double zwA;
+        final double zwB;
 
         if (yStart > yEnd) {
             final int tmp = yStart;
@@ -255,9 +302,14 @@ public class Line extends AbstractCoordinateShape {
             yEnd = tmp;
             lineWidth = (int) (onScreenPoint1.x - onScreenPoint2.x);
             xBase = (int) onScreenPoint2.x;
+            // walk runs from endpoint 2 to endpoint 1
+            zwA = zwP2;
+            zwB = zwP1;
         } else {
             xBase = (int) onScreenPoint1.x;
             lineWidth = (int) (onScreenPoint2.x - onScreenPoint1.x);
+            zwA = zwP1;
+            zwB = zwP2;
         }
 
         final int lineHeight = yEnd - yStart;
@@ -265,6 +317,7 @@ public class Line extends AbstractCoordinateShape {
             return;
 
         final int[] pixels = buffer.pixels;
+        final float[] depth = buffer.depth;
         final int backgroundAlpha = 255 - alpha;
 
         final int redWithAlpha = color.r * alpha;
@@ -281,16 +334,21 @@ public class Line extends AbstractCoordinateShape {
                     if ((x >= buffer.renderMinX) && (x < buffer.renderMaxX)) {
                         int offset = (y * buffer.width) + x;
 
-                        final int dest = pixels[offset];
-                        final int destR = (dest >> 16) & 0xff;
-                        final int destG = (dest >> 8) & 0xff;
-                        final int destB = dest & 0xff;
-
-                        final int newR = ((destR * backgroundAlpha) + redWithAlpha) >> 8;
-                        final int newG = ((destG * backgroundAlpha) + greenWithAlpha) >> 8;
-                        final int newB = ((destB * backgroundAlpha) + blueWithAlpha) >> 8;
-
-                        pixels[offset] = (newR << 16) | (newG << 8) | newB;
+                        // depth-test (never write): solids occlude lines
+                        final double zw = zwA
+                                + (((double) relativeY / lineHeight) * (zwB - zwA));
+                        if (zw > depth[offset] - RenderingContext.DEPTH_MARGIN_DZ * zw * zw) {
+                            final int dest = pixels[offset];
+                            final int destR = (dest >> 16) & 0xff;
+                            final int destG = (dest >> 8) & 0xff;
+                            final int destB = dest & 0xff;
+
+                            final int newR = ((destR * backgroundAlpha) + redWithAlpha) >> 8;
+                            final int newG = ((destG * backgroundAlpha) + greenWithAlpha) >> 8;
+                            final int newB = ((destB * backgroundAlpha) + blueWithAlpha) >> 8;
+
+                            pixels[offset] = (newR << 16) | (newG << 8) | newB;
+                        }
                     }
                 }
             }
@@ -356,8 +414,9 @@ public class Line extends AbstractCoordinateShape {
      */
     @Override
     public void paint(final RenderingContext buffer) {
-        // Lines don't participate in depth (overlay semantics) — paint
-        // only in the alpha pass.
+        // Lines paint only in the alpha pass: they blend, depth-TEST
+        // against the opaque z-buffer (solid geometry occludes them),
+        // and never depth-WRITE (a line must not occlude later geometry).
         if (buffer.depthPass == 1)
             return;
 
@@ -373,10 +432,16 @@ public class Line extends AbstractCoordinateShape {
         final double xp = onScreenPoint2.x - onScreenPoint1.x;
         final double yp = onScreenPoint2.y - onScreenPoint1.y;
 
-        final double point1radius = (buffer.width * LINE_WIDTH_MULTIPLIER * width)
-                / endpoint1.transformedCoordinate(buffer).z;
-        final double point2radius = (buffer.width * LINE_WIDTH_MULTIPLIER * width)
-                / endpoint2.transformedCoordinate(buffer).z;
+        final double z1 = endpoint1.transformedCoordinate(buffer).z;
+        final double z2 = endpoint2.transformedCoordinate(buffer).z;
+
+        final double point1radius = (buffer.width * LINE_WIDTH_MULTIPLIER * width) / z1;
+        final double point2radius = (buffer.width * LINE_WIDTH_MULTIPLIER * width) / z2;
+
+        // 1/z at the endpoints: interpolates linearly in screen space
+        // (projectively correct) — the basis for per-pixel depth tests.
+        final double zwP1 = 1d / z1;
+        final double zwP2 = 1d / z2;
 
         if ((point1radius < MINIMUM_WIDTH_THRESHOLD)
                 || (point2radius < MINIMUM_WIDTH_THRESHOLD)) {
@@ -391,9 +456,9 @@ public class Line extends AbstractCoordinateShape {
                 return;
 
             if (Math.abs(xp) > Math.abs(yp))
-                drawSinglePixelHorizontalLine(buffer, alpha, onScreenPoint1, onScreenPoint2);
+                drawSinglePixelHorizontalLine(buffer, alpha, onScreenPoint1, onScreenPoint2, zwP1, zwP2);
             else
-                drawSinglePixelVerticalLine(buffer, alpha, onScreenPoint1, onScreenPoint2);
+                drawSinglePixelVerticalLine(buffer, alpha, onScreenPoint1, onScreenPoint2, zwP1, zwP2);
             return;
         }
 
@@ -452,12 +517,19 @@ public class Line extends AbstractCoordinateShape {
         if (ymin > ymax)
             return;
 
+        // 2D-line parameter gradients for per-pixel 1/z interpolation
+        final double len2 = (xp * xp) + (yp * yp);
+        final double dtDx = xp / len2;
+        final double dtDy = yp / len2;
+
         for (int y = (int) ymin; y <= ymax; y++) {
             final int li1 = getLineInterpolator(lineInterpolators, 0, y);
             if (li1 != -1) {
                 final int li2 = getLineInterpolator(lineInterpolators, li1 + 1, y);
                 if (li2 != -1)
-                    drawHorizontalLine(lineInterpolators[li1], lineInterpolators[li2], y, buffer);
+                    drawHorizontalLine(lineInterpolators[li1], lineInterpolators[li2], y,
+                            buffer, onScreenPoint1.x, onScreenPoint1.y, dtDx, dtDy,
+                            zwP1, zwP2 - zwP1);
             }
         }
     }