2 * Aukio 3D engine. Author: Svjatoslav Agejenko.
3 * This project is released under Creative Commons Zero (CC0) license.
5 package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base;
7 import eu.svjatoslav.aukio.e3d.geometry.Box;
8 import eu.svjatoslav.aukio.e3d.renderer.raster.Frustum;
9 import eu.svjatoslav.aukio.e3d.geometry.Point3D;
10 import eu.svjatoslav.aukio.e3d.renderer.raster.RenderingContext;
11 import eu.svjatoslav.aukio.e3d.gui.ViewSpaceTracker;
12 import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController;
13 import eu.svjatoslav.aukio.e3d.math.Transform;
14 import eu.svjatoslav.aukio.e3d.math.TransformStack;
15 import eu.svjatoslav.aukio.e3d.renderer.raster.Vertex;
16 import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
17 import eu.svjatoslav.aukio.e3d.renderer.raster.ParallelTransformCoordinator;
18 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
19 import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator;
20 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape;
21 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line;
22 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
23 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle;
25 import java.util.ArrayList;
26 import java.util.Iterator;
27 import java.util.List;
28 import java.util.concurrent.atomic.AtomicInteger;
31 * A composite shape that groups multiple sub-shapes into a single logical unit.
33 * <p>Use {@code AbstractCompositeShape} to build complex 3D objects by combining
34 * primitive shapes (lines, polygons, textured polygons) into a group that can be
35 * positioned, rotated, and manipulated as one entity. Sub-shapes can be organized
36 * into named groups for selective visibility toggling.</p>
38 * <p><b>Usage example - creating a custom composite shape:</b></p>
40 * // Create a composite shape at position (0, 0, 200)
41 * AbstractCompositeShape myObject = new AbstractCompositeShape(
42 * new Point3D(0, 0, 200)
46 * myObject.addShape(new Line(
47 * new Point3D(-50, 0, 0), new Point3D(50, 0, 0),
51 * // Add shapes to a named group for toggling visibility
52 * myObject.addShape(labelShape, "labels");
53 * myObject.hideGroup("labels"); // hide all shapes in "labels" group
54 * myObject.showGroup("labels"); // show them again
57 * viewPanel.getRootShapeCollection().addShape(myObject);
60 * <p><b>Perspective-correct texturing:</b></p>
61 * <p>Textured polygons are rendered with Quake-style perspective-correct scanline
62 * mapping ({@code TexturedTriangle}), so no screen-size tessellation is needed.</p>
64 * <p><b>Extending this class:</b></p>
65 * <p>Override {@link #beforeTransformHook} to customize shape appearance or behavior
66 * on each frame (e.g., animations, dynamic geometry updates).</p>
68 * @see SubShape wrapper for individual sub-shapes with group and visibility support
69 * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape the base shape class
71 public class AbstractCompositeShape extends AbstractShape {
73 * Source-of-truth registry of all sub-shapes added to this composite.
75 * <p>Each sub-shape is wrapped with its group identifier and visibility state.
76 * Shapes are stored in insertion order and remain in this collection even when
77 * hidden (visibility state toggles instead of removal).</p>
79 * <p><b>Performance note:</b> This list is NOT processed for every frame.
80 * Instead, it serves as the authoritative source from which {@link #cachedRenderList}
81 * is compiled whenever the cache becomes invalid (see {@link #cacheNeedsRebuild}).
82 * Only modifications to this registry (add/remove/show/hide) trigger cache rebuild.</p>
84 * @see #cachedRenderList the frame-optimized cache derived from this registry
85 * @see #cacheNeedsRebuild the flag controlling when the cache is rebuilt
87 private final List<SubShape> subShapesRegistry = new ArrayList<>();
90 * Tracks the distance and angle between the camera and this shape.
91 * Used e.g. by TextCanvas for distance-based rendering mode selection.
93 private final ViewSpaceTracker viewSpaceTracker;
96 * Frame-optimized cache of shapes ready for rendering, derived from {@link #subShapesRegistry}.
98 * <p>This list is processed during every frame in the {@link #transform} method.
101 * <li>Shapes passing through directly (Line, TexturedTriangle, ...)</li>
102 * <li>Solid polygons with more than 3 vertices - fan-triangulated</li>
105 * <p><b>Caching strategy:</b> The list is rebuilt only when
106 * {@link #cacheNeedsRebuild} is true, avoiding per-frame reconstruction
109 * @see #subShapesRegistry the source registry this cache is derived from
110 * @see #cacheNeedsRebuild the flag that triggers cache regeneration
112 private List<AbstractShape> cachedRenderList = new ArrayList<>();
115 * Flag indicating whether {@link #cachedRenderList} needs to be rebuilt from {@link #subShapesRegistry}.
117 * <p>Set to {@code true} when:</p>
119 * <li>A shape is added via {@link #addShape}</li>
120 * <li>A shape is removed via {@link #removeGroup}</li>
121 * <li>Group visibility changes via {@link #showGroup} or {@link #hideGroup}</li>
124 * <p>Set to {@code false} after {@link #rebuildRenderList} completes the cache rebuild.</p>
126 * <p>This flag enables the performance optimization of avoiding per-frame list
127 * reconstruction - the registry is only re-processed when something actually changed.</p>
129 * @see #subShapesRegistry the source data that may need reprocessing
130 * @see #cachedRenderList the cache that gets rebuilt when this flag is true
132 private boolean cacheNeedsRebuild = true;
135 * Flag indicating this composite is the root scene container (ShapeCollection's root).
137 * <p>Set via {@link #setRootComposite(boolean)} by ShapeCollection.</p>
139 private boolean isRootComposite = false;
142 * The position and orientation transform for this composite shape.
143 * Applied to all sub-shapes during the rendering transform pass.
145 private Transform transform;
148 * Creates a composite shape at the world origin with no rotation.
150 public AbstractCompositeShape() {
151 this(new Transform());
155 * Creates a composite shape at the specified location with no rotation.
157 * @param location the position in world space
159 public AbstractCompositeShape(final Point3D location) {
160 this(new Transform(location));
164 * Creates a composite shape with the specified transform (position and orientation).
166 * @param transform the initial transform defining position and rotation
168 public AbstractCompositeShape(final Transform transform) {
169 this.transform = transform;
170 viewSpaceTracker = new ViewSpaceTracker();
174 * Adds a sub-shape to this composite shape without a group identifier.
176 * @param shape the shape to add
178 public void addShape(final AbstractShape shape) {
179 addShape(shape, null);
183 * Adds a sub-shape to this composite shape with an optional group identifier.
185 * <p>Grouped shapes can be shown, hidden, or removed together using
186 * {@link #showGroup}, {@link #hideGroup}, and {@link #removeGroup}.</p>
188 * @param shape the shape to add
189 * @param groupId the group identifier, or {@code null} for ungrouped shapes
191 public void addShape(final AbstractShape shape, final String groupId) {
192 subShapesRegistry.add(new SubShape(shape, groupId, true));
193 cacheNeedsRebuild = true;
197 * This method should be overridden by anyone wanting to customize the shape
198 * before it is rendered.
200 * @param transformPipe the current transform stack
201 * @param context the rendering context for the current frame
203 public void beforeTransformHook(final TransformStack transformPipe,
204 final RenderingContext context) {
208 * Returns the world-space position of this composite shape.
210 * @return the translation component of this shape's transform
212 public Point3D getLocation() {
213 return transform.getTranslation();
217 * Returns the axis-aligned bounding box encompassing all sub-shapes.
219 * <p>The bounding box is computed by aggregating the bounds of all visible
220 * sub-shapes, then transforming the result by this composite's own transform.</p>
222 * <p><b>Caching:</b> The bounding box is recomputed whenever
223 * {@link #cacheNeedsRebuild} is true (shapes added/removed/visibility changed).
224 * For nested composites, the bounds include their local transform offset.</p>
226 * @return the axis-aligned bounding box in this composite's local coordinates
229 public Box getBoundingBox() {
230 if (cachedBoundingBox == null || cacheNeedsRebuild) {
231 if (subShapesRegistry.isEmpty()) {
232 return super.getBoundingBox();
235 double minX = Double.MAX_VALUE;
236 double maxX = -Double.MAX_VALUE;
237 double minY = Double.MAX_VALUE;
238 double maxY = -Double.MAX_VALUE;
239 double minZ = Double.MAX_VALUE;
240 double maxZ = -Double.MAX_VALUE;
242 for (final SubShape subShape : subShapesRegistry) {
243 if (!subShape.isVisible()) {
247 final AbstractShape shape = subShape.getShape();
248 final Box shapeBounds = shape.getBoundingBox();
250 // Get bounds and apply sub-shape's transform if it's a composite
251 Point3D shapeMin = new Point3D(shapeBounds.getMinX(), shapeBounds.getMinY(), shapeBounds.getMinZ());
252 Point3D shapeMax = new Point3D(shapeBounds.getMaxX(), shapeBounds.getMaxY(), shapeBounds.getMaxZ());
253 if (shape instanceof AbstractCompositeShape) {
254 final Transform subTransform = ((AbstractCompositeShape) shape).getTransform();
255 final Point3D subTranslation = subTransform.getTranslation();
256 shapeMin.add(subTranslation);
257 shapeMax.add(subTranslation);
260 minX = Math.min(minX, shapeMin.x);
261 maxX = Math.max(maxX, shapeMax.x);
262 minY = Math.min(minY, shapeMin.y);
263 maxY = Math.max(maxY, shapeMax.y);
264 minZ = Math.min(minZ, shapeMin.z);
265 maxZ = Math.max(maxZ, shapeMax.z);
268 if (minX == Double.MAX_VALUE) {
270 return super.getBoundingBox();
273 cachedBoundingBox = new Box(
274 new Point3D(minX, minY, minZ),
275 new Point3D(maxX, maxY, maxZ)
278 return cachedBoundingBox;
282 * Returns the sub-shapes registry (source of truth for all sub-shapes).
284 * <p>This is the authoritative list of all sub-shapes including hidden ones.
285 * For per-frame rendering, use {@link #cachedRenderList} instead (accessed internally).</p>
287 * @return the registry list of all sub-shapes with their group and visibility metadata
288 * @see #cachedRenderList the frame-optimized cache derived from this registry
290 public List<SubShape> getSubShapesRegistry() {
291 return subShapesRegistry;
295 * Extracts all SolidPolygon instances from this composite shape.
297 * <p>Recursively traverses the shape hierarchy and collects all
298 * SolidPolygon instances. Used for CSG operations where polygons
299 * are needed directly without conversion.</p>
301 * @return list of SolidPolygon instances from this shape hierarchy
303 public List<SolidPolygon> extractSolidPolygons() {
304 final List<SolidPolygon> result = new ArrayList<>();
305 for (final SubShape subShape : subShapesRegistry) {
306 final AbstractShape shape = subShape.getShape();
307 if (shape instanceof SolidPolygon) {
308 result.add((SolidPolygon) shape);
309 } else if (shape instanceof AbstractCompositeShape) {
310 result.addAll(((AbstractCompositeShape) shape).extractSolidPolygons());
317 * Returns the view-space tracker that monitors the distance
318 * and angle between the camera and this shape for level-of-detail adjustments.
320 * @return the view-space tracker for this shape
322 public ViewSpaceTracker getViewSpaceTracker() {
323 return viewSpaceTracker;
327 * Hides all sub-shapes belonging to the specified group.
328 * Hidden shapes are not rendered but remain in the collection.
330 * @param groupIdentifier the group to hide
331 * @see #showGroup(String)
332 * @see #removeGroup(String)
334 public void hideGroup(final String groupIdentifier) {
335 for (final SubShape subShape : subShapesRegistry) {
336 if (subShape.matchesGroup(groupIdentifier)) {
337 subShape.setVisible(false);
338 cacheNeedsRebuild = true;
344 * Permanently removes all sub-shapes belonging to the specified group.
346 * @param groupIdentifier the group to remove
347 * @see #hideGroup(String)
349 public void removeGroup(final String groupIdentifier) {
350 final java.util.Iterator<SubShape> iterator = subShapesRegistry
353 while (iterator.hasNext()) {
354 final SubShape subShape = iterator.next();
355 if (subShape.matchesGroup(groupIdentifier)) {
357 cacheNeedsRebuild = true;
363 * Returns all sub-shapes belonging to the specified group.
365 * @param groupIdentifier the group identifier to match
366 * @return list of matching sub-shapes
368 public List<SubShape> getGroup(final String groupIdentifier) {
369 final List<SubShape> result = new ArrayList<>();
370 for (int i = 0; i < subShapesRegistry.size(); i++) {
371 final SubShape subShape = subShapesRegistry.get(i);
372 if (subShape.matchesGroup(groupIdentifier))
373 result.add(subShape);
379 * Rebuilds the cached render list if shapes were added, removed, or
380 * visibility changed since the last rebuild.
382 * @param context the rendering context for logging
384 private void rebuildRenderListIfNeeded(final RenderingContext context) {
385 if (cacheNeedsRebuild)
386 rebuildRenderList(context);
390 * Paint solid elements of this composite shape into given color.
392 * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
394 * @param color the color to apply to all solid sub-shapes
396 public void setColor(final Color color) {
397 for (final SubShape subShape : getSubShapesRegistry()) {
398 final AbstractShape shape = subShape.getShape();
400 if (shape instanceof SolidPolygon) {
401 ((SolidPolygon) shape).setColor(color);
402 } else if (shape instanceof Line) {
403 ((Line) shape).color = color;
404 } else if (shape instanceof AbstractCompositeShape) {
405 ((AbstractCompositeShape) shape).setColor(color);
411 * Assigns a group identifier to all sub-shapes that currently have no group.
413 * @param groupIdentifier the group to assign to ungrouped shapes
415 public void setGroupForUngrouped(final String groupIdentifier) {
416 for (final SubShape subShape : subShapesRegistry)
417 if (subShape.isUngrouped())
418 subShape.setGroup(groupIdentifier);
422 public void setMouseInteractionController(
423 final MouseInteractionController mouseInteractionController) {
424 super.setMouseInteractionController(mouseInteractionController);
426 for (final SubShape subShape : subShapesRegistry)
427 subShape.getShape().setMouseInteractionController(
428 mouseInteractionController);
430 cacheNeedsRebuild = true;
434 * Marks this composite as the root scene container.
436 * <p>Called by {@code ShapeCollection} to configure its root composite.</p>
438 * @param isRoot {@code true} if this is the root composite, {@code false} otherwise
440 public void setRootComposite(final boolean isRoot) {
441 this.isRootComposite = isRoot;
445 * Returns this composite's transform (position and orientation).
447 * @return the transform object
449 public Transform getTransform() {
454 * Sets the transform for this composite shape.
456 * @param transform the new transform
457 * @return this composite shape (for chaining)
459 public AbstractCompositeShape setTransform(final Transform transform) {
460 this.transform = transform;
465 * Sets the cache rebuild flag on this composite and all nested composites recursively.
467 * <p>Used by {@code ShapeCollection} to trigger a render-list rebuild when
468 * clearing the scene or for other advanced use cases.</p>
470 * @param needsRebuild {@code true} to force cache rebuild on next frame
472 public void setCacheNeedsRebuild(final boolean needsRebuild) {
473 this.cacheNeedsRebuild = needsRebuild;
474 // Propagate to nested composites
475 for (final SubShape subShape : subShapesRegistry) {
476 final AbstractShape shape = subShape.getShape();
477 if (shape instanceof AbstractCompositeShape composite) {
478 composite.setCacheNeedsRebuild(needsRebuild);
484 * Enables or disables shading for all SolidTriangle and SolidPolygon sub-shapes.
485 * When enabled, shapes use the global lighting manager from the rendering
486 * context to calculate flat shading based on light sources.
488 * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
490 * @param shadingEnabled {@code true} to enable shading, {@code false} to disable
491 * @return this composite shape (for chaining)
493 public AbstractCompositeShape setShadingEnabled(final boolean shadingEnabled) {
494 for (final SubShape subShape : getSubShapesRegistry()) {
495 final AbstractShape shape = subShape.getShape();
496 if (shape instanceof SolidPolygon) {
497 ((SolidPolygon) shape).setShadingEnabled(shadingEnabled);
498 } else if (shape instanceof AbstractCompositeShape) {
499 ((AbstractCompositeShape) shape).setShadingEnabled(shadingEnabled);
506 * Enables or disables backface culling for all SolidPolygon and TexturedTriangle sub-shapes.
508 * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
510 * @param backfaceCulling {@code true} to enable backface culling, {@code false} to disable
511 * @return this composite shape (for chaining)
513 public AbstractCompositeShape setBackfaceCulling(final boolean backfaceCulling) {
514 for (final SubShape subShape : getSubShapesRegistry()) {
515 final AbstractShape shape = subShape.getShape();
516 if (shape instanceof SolidPolygon) {
517 ((SolidPolygon) shape).setBackfaceCulling(backfaceCulling);
518 } else if (shape instanceof TexturedTriangle) {
519 ((TexturedTriangle) shape).setBackfaceCulling(backfaceCulling);
520 } else if (shape instanceof AbstractCompositeShape) {
521 ((AbstractCompositeShape) shape).setBackfaceCulling(backfaceCulling);
528 * Performs an in-place union with another composite shape.
530 * <p>This shape's SolidPolygon children are replaced with the union result.
531 * Non-SolidPolygon children from both shapes are preserved and combined.</p>
533 * <p><b>CSG Operation:</b> Union combines two shapes into one, keeping all
534 * geometry from both. Uses BSP tree algorithms for robust boolean operations.</p>
536 * <p><b>Child handling:</b></p>
538 * <li>SolidPolygon children from both shapes → replaced with union result</li>
539 * <li>Non-SolidPolygon children from this shape → preserved</li>
540 * <li>Non-SolidPolygon children from other shape → added to this shape</li>
541 * <li>Nested AbstractCompositeShape children → preserved unchanged (not recursively processed)</li>
544 * @param other the shape to union with
545 * @see #subtract(AbstractCompositeShape)
546 * @see #intersect(AbstractCompositeShape)
548 public void union(final AbstractCompositeShape other) {
550 final BspTree selfTree = new BspTree(clonePolygons(extractSolidPolygons()));
551 final BspTree otherTree = new BspTree(clonePolygons(other.extractSolidPolygons()));
553 // Remove from self any polygons that are inside other (interior faces)
554 selfTree.clipTo(otherTree);
556 // Remove from other any polygons that are inside self (interior faces)
557 otherTree.clipTo(selfTree);
559 // Invert other to convert remaining polygons for the next clip step
562 // Clip inverted other against self to remove back-facing coplanar polygons
563 otherTree.clipTo(selfTree);
565 // Invert back to restore correct polygon orientation
568 // Merge other's remaining polygons into self's BSP tree
569 selfTree.addPolygons(otherTree.allPolygons());
571 replaceSolidPolygons(selfTree.allPolygons());
572 mergeNonPolygonChildrenFrom(other);
576 * Performs an in-place subtraction with another composite shape.
578 * <p>This shape's SolidPolygon children are replaced with the difference result.
579 * The other shape acts as a "cutter" that carves out volume from this shape.</p>
581 * <p><b>CSG Operation:</b> Subtract removes the volume of the second shape
582 * from the first shape. Useful for creating holes, cavities, and cutouts.</p>
584 * <p><b>Child handling:</b></p>
586 * <li>SolidPolygon children from this shape → replaced with difference result</li>
587 * <li>Non-SolidPolygon children from this shape → preserved</li>
588 * <li>All children from other shape → discarded (other is just a cutter)</li>
589 * <li>Nested AbstractCompositeShape children → preserved unchanged</li>
592 * @param other the shape to subtract (the cutter)
593 * @see #union(AbstractCompositeShape)
594 * @see #intersect(AbstractCompositeShape)
596 public void subtract(final AbstractCompositeShape other) {
598 final BspTree target = new BspTree(clonePolygons(extractSolidPolygons()));
599 final BspTree cutter = new BspTree(clonePolygons(other.extractSolidPolygons()));
601 // Invert target: convert "inside" to "outside" and vice versa
602 // This transforms the problem from "subtract B from A" to "intersect A's complement with B's complement"
605 // Clip target against cutter: removes parts of target that are INSIDE the cutter
606 // Since target is inverted, this removes parts that were OUTSIDE the original target
607 target.clipTo(cutter);
609 // Clip cutter against (inverted) target: removes parts of cutter outside the inverted target
610 // This keeps only cutter polygons that are inside the inverted target = outside original target
611 cutter.clipTo(target);
613 // Invert cutter to flip its inside/outside
616 // Clip inverted cutter against target: removes coplanar back-faces
617 cutter.clipTo(target);
619 // Invert cutter back to correct orientation
622 // Merge cutter's polygons into target's BSP tree
623 target.addPolygons(cutter.allPolygons());
625 // Invert target back to restore correct inside/outside orientation
626 // Result: the carved-out volume (target minus cutter)
629 replaceSolidPolygons(target.allPolygons());
633 * Performs an in-place intersection with another composite shape.
635 * <p>This shape's SolidPolygon children are replaced with the intersection result.
636 * Only the overlapping volume between the two shapes remains.</p>
638 * <p><b>CSG Operation:</b> Intersect keeps only the volume where both shapes
639 * overlap. Useful for creating shapes constrained by multiple boundaries.</p>
641 * <p><b>Child handling:</b></p>
643 * <li>SolidPolygon children from this shape → replaced with intersection result</li>
644 * <li>Non-SolidPolygon children from this shape → preserved</li>
645 * <li>All children from other shape → discarded</li>
646 * <li>Nested AbstractCompositeShape children → preserved unchanged</li>
649 * @param other the shape to intersect with
650 * @see #union(AbstractCompositeShape)
651 * @see #subtract(AbstractCompositeShape)
653 public void intersect(final AbstractCompositeShape other) {
655 final BspTree selfTree = new BspTree(clonePolygons(extractSolidPolygons()));
656 final BspTree otherTree = new BspTree(clonePolygons(other.extractSolidPolygons()));
658 // Invert self to convert "inside" to "outside"
659 // This transforms intersection into: keep parts that are "outside both inverted shapes"
662 // Clip other against inverted self: keeps only parts of other that are INSIDE original self
663 // (because clipTo removes what's "outside" the BSP, and inverted self's "outside" = original self's "inside")
664 otherTree.clipTo(selfTree);
666 // Invert other (which now represents the intersection region)
669 // Clip inverted self against (inverted intersection): removes parts outside the intersection
670 selfTree.clipTo(otherTree);
672 // Clip intersection result against inverted self: removes back-facing coplanar polygons
673 otherTree.clipTo(selfTree);
675 // Build final BSP tree from the clipped intersection polygons
676 selfTree.addPolygons(otherTree.allPolygons());
678 // Invert back to restore correct inside/outside orientation
681 replaceSolidPolygons(selfTree.allPolygons());
685 * Creates deep clones of all polygons in the list.
687 * <p>CSG operations modify polygons in-place via BSP tree operations.
688 * Cloning ensures the original polygon data is preserved.</p>
690 * @param polygons the polygons to clone
691 * @return a new list containing deep clones of all polygons
693 private List<SolidPolygon> clonePolygons(final List<SolidPolygon> polygons) {
694 final List<SolidPolygon> cloned = new ArrayList<>(polygons.size());
695 for (final SolidPolygon p : polygons) {
696 cloned.add(p.deepClone());
702 * Replaces this shape's SolidPolygon children with new polygons.
704 * <p>Preserves all non-SolidPolygon children (Lines, nested composites, etc.).</p>
706 * @param newPolygons the polygons to replace with
708 private void replaceSolidPolygons(final List<SolidPolygon> newPolygons) {
709 // Remove all direct SolidPolygon children from this shape
710 final Iterator<SubShape> iterator = subShapesRegistry.iterator();
711 while (iterator.hasNext()) {
712 final SubShape subShape = iterator.next();
713 if (subShape.getShape() instanceof SolidPolygon) {
718 // Add all result polygons as new children
719 for (final SolidPolygon polygon : newPolygons) {
723 cacheNeedsRebuild = true;
727 * Merges non-SolidPolygon children from another shape into this shape.
729 * <p>Copies all non-SolidPolygon children (Lines, nested composites, etc.)
730 * from the other shape, preserving their group identifiers.</p>
732 * @param other the shape to merge non-polygon children from
734 private void mergeNonPolygonChildrenFrom(final AbstractCompositeShape other) {
739 for (final SubShape otherSubShape : other.subShapesRegistry) {
740 final AbstractShape otherShape = otherSubShape.getShape();
741 if (!(otherShape instanceof SolidPolygon)) {
742 addShape(otherShape, otherSubShape.getGroupIdentifier());
746 cacheNeedsRebuild = true;
750 * Makes all sub-shapes belonging to the specified group visible.
752 * @param groupIdentifier the group to show
753 * @see #hideGroup(String)
755 public void showGroup(final String groupIdentifier) {
756 for (int i = 0; i < subShapesRegistry.size(); i++) {
757 final SubShape subShape = subShapesRegistry.get(i);
758 if (subShape.matchesGroup(groupIdentifier)) {
759 subShape.setVisible(true);
760 cacheNeedsRebuild = true;
766 * Rebuilds the cached render list from the shape registry:
767 * textured triangles pass through as-is (perspective-correct scanline
768 * rendering needs no tessellation), N-vertex solid polygons are
769 * fan-triangulated, everything else passes through.
770 * Logs the operation to the debug log buffer if available.
772 * @param context the rendering context for logging, may be {@code null}
774 private void rebuildRenderList(final RenderingContext context) {
775 cacheNeedsRebuild = false;
777 final List<AbstractShape> result = new ArrayList<>();
778 int texturedPolygonCount = 0;
779 int solidPolygonCount = 0;
780 int triangulatedPolygonCount = 0;
781 int otherShapeCount = 0;
783 for (int i = 0; i < subShapesRegistry.size(); i++) {
784 final SubShape subShape = subShapesRegistry.get(i);
785 if (!subShape.isVisible())
788 final AbstractShape shape = subShape.getShape();
790 if (shape instanceof TexturedTriangle) {
792 texturedPolygonCount++;
793 } else if (shape instanceof SolidPolygon polygon) {
794 final int vertexCount = polygon.getVertexCount();
796 if (vertexCount == 3) {
800 triangulateSolidPolygon(polygon, result);
801 triangulatedPolygonCount++;
809 cachedRenderList = postprocessRenderList(result);
811 globalRenderListVersion.incrementAndGet();
813 if (context != null && context.debugLogBuffer != null) {
814 context.debugLogBuffer.log("rebuildRenderList: " + getClass().getSimpleName()
815 + " texturedPolygons=" + texturedPolygonCount
816 + " solidPolygons=" + solidPolygonCount
817 + " triangulatedPolygons=" + triangulatedPolygonCount
818 + " otherShapes=" + otherShapeCount);
823 * Returns the global render list version: incremented every time ANY
824 * composite's render list is rebuilt. Used by derived structures
825 * (BSP trees, GI scene snapshots) to detect that they must rebuild.
827 * @return monotonically increasing global version
829 public static int getGlobalRenderListVersion() {
830 return globalRenderListVersion.get();
834 * Collects the triangles of this composite's current render list,
835 * recursing into nested composites. These are the exact objects that
836 * get transformed and rendered: triangulated render-list polygons, or
837 * lightmapped wrappers for
838 * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}.
840 * <p>Render lists are built lazily during transform; composites that
841 * have not been transformed yet (or are frustum-culled) contribute
842 * nothing. Vertices are in each composite's local space — callers
843 * combining several composites should require identity transforms.</p>
845 * @param out list receiving the triangles
847 public void collectRenderTriangles(final List<AbstractCoordinateShape> out) {
848 if (cachedRenderList == null)
850 for (final AbstractShape shape : cachedRenderList) {
851 if (shape instanceof SolidPolygon
852 || shape instanceof eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle)
853 out.add((AbstractCoordinateShape) shape);
854 else if (shape instanceof AbstractCompositeShape)
855 ((AbstractCompositeShape) shape).collectRenderTriangles(out);
860 * Hook: post-processes the freshly rebuilt render list before it becomes
861 * the rendering cache. The default implementation returns the list
862 * unchanged. Subclasses may replace the list — e.g.
863 * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}
864 * wraps its polygons into lightmapped triangles here.
866 * @param renderList the render list built from the shape registry
867 * @return the render list to cache and render
869 protected List<AbstractShape> postprocessRenderList(final List<AbstractShape> renderList) {
874 * Triangulates a convex solid polygon using fan triangulation.
876 * <p>Fan triangulation creates N-2 triangles from an N-vertex polygon by using
877 * vertex 0 as the anchor and connecting it to each adjacent pair of vertices.</p>
879 * <p>Properties (color, shading, backface culling, mouse interaction) are
880 * propagated to each resulting triangle to ensure consistent behavior.</p>
882 * @param polygon the polygon to triangulate (must have at least 4 vertices)
883 * @param result the list to add the resulting triangles to
885 private void triangulateSolidPolygon(final SolidPolygon polygon,
886 final List<AbstractShape> result) {
888 final Color color = polygon.getColor();
889 final boolean shadingEnabled = polygon.isShadingEnabled();
890 final boolean backfaceCulling = polygon.isBackfaceCullingEnabled();
891 final MouseInteractionController mouseController = polygon.mouseInteractionController;
893 final List<Vertex> vertices = polygon.vertices;
894 final Vertex v0 = vertices.get(0);
896 for (int i = 1; i < vertices.size() - 1; i++) {
897 final Vertex v1 = vertices.get(i);
898 final Vertex v2 = vertices.get(i + 1);
900 final SolidPolygon triangle = new SolidPolygon(
901 v0.coordinate, v1.coordinate, v2.coordinate, color);
903 triangle.setShadingEnabled(shadingEnabled);
904 triangle.setBackfaceCulling(backfaceCulling);
905 triangle.setMouseInteractionController(mouseController);
907 result.add(triangle);
912 public void transform(final TransformStack transformPipe,
913 final RenderAggregator aggregator, final RenderingContext context) {
915 // Add the current composite shape transform to the end of the transform
917 transformPipe.addTransform(transform);
919 // FRUSTUM CULLING: Check if this composite's bounds are visible
920 // Root composite skips this check (its bounds are always the full scene)
921 // Non-root composites check their aggregated bounds against the frustum
922 if (context.frustum != null && !isRootComposite) {
923 // Count this composite for culling statistics (before frustum test)
924 if (context.cullingStatistics != null) {
925 context.cullingStatistics.totalComposites.incrementAndGet();
928 final Box localBounds = getBoundingBox();
930 // Transform all 8 corners of the bounding box to view space
931 final double minX = localBounds.getMinX();
932 final double maxX = localBounds.getMaxX();
933 final double minY = localBounds.getMinY();
934 final double maxY = localBounds.getMaxY();
935 final double minZ = localBounds.getMinZ();
936 final double maxZ = localBounds.getMaxZ();
938 final double[] xs = {minX, maxX};
939 final double[] ys = {minY, maxY};
940 final double[] zs = {minZ, maxZ};
942 double viewMinX = Double.MAX_VALUE;
943 double viewMaxX = -Double.MAX_VALUE;
944 double viewMinY = Double.MAX_VALUE;
945 double viewMaxY = -Double.MAX_VALUE;
946 double viewMinZ = Double.MAX_VALUE;
947 double viewMaxZ = -Double.MAX_VALUE;
949 for (int i = 0; i < 8; i++) {
950 final double x = xs[(i & 1)];
951 final double y = ys[(i >> 1) & 1];
952 final double z = zs[(i >> 2) & 1];
954 final Point3D corner = transformPointToViewSpace(x, y, z, transformPipe);
956 viewMinX = Math.min(viewMinX, corner.x);
957 viewMaxX = Math.max(viewMaxX, corner.x);
958 viewMinY = Math.min(viewMinY, corner.y);
959 viewMaxY = Math.max(viewMaxY, corner.y);
960 viewMinZ = Math.min(viewMinZ, corner.z);
961 viewMaxZ = Math.max(viewMaxZ, corner.z);
964 final Box viewSpaceBounds = new Box(
965 new Point3D(viewMinX, viewMinY, viewMinZ),
966 new Point3D(viewMaxX, viewMaxY, viewMaxZ)
969 final Frustum frustum = context.frustum;
970 final boolean visible = frustum.intersectsAABB(viewSpaceBounds);
973 // Entire composite outside frustum - skip processing all children
974 if (context.cullingStatistics != null) {
975 context.cullingStatistics.culledComposites.incrementAndGet();
977 transformPipe.dropTransform();
982 viewSpaceTracker.analyze(transformPipe, context);
984 beforeTransformHook(transformPipe, context);
986 rebuildRenderListIfNeeded(context);
988 // transform rendered subshapes
989 if (shouldForkTransform(context)) {
990 transformChildrenParallel(transformPipe, aggregator, context);
992 transformChildrenSerial(transformPipe, aggregator, context);
995 transformPipe.dropTransform();
999 * Minimum number of children before the parallel fork is considered at
1000 * all. A single child cannot be split; splitting happens in the child.
1002 private static final int PARALLEL_TRANSFORM_MIN_SHAPES = 2;
1005 * Minimum total subtree weight (leaf primitives below this composite)
1006 * before forking its transform into parallel chunks. Below this, the
1007 * serial walk is cheaper than the fork overhead. Deliberately above
1008 * one sphere's generated triangle count (~960 at 16 segments):
1009 * measured 2026-09-04, forking those pays task overhead per chunk for
1010 * negligible serial work (35k chunk tasks on a 3000-sphere scene were
1011 * SLOWER than serial).
1013 private static final int PARALLEL_TRANSFORM_MIN_SUBTREE_WEIGHT = 2048;
1016 * Minimum weight per parallel chunk task. Keeps chunk granularity
1017 * coarse enough that task dispatch overhead stays negligible.
1019 private static final int PARALLEL_TASK_MIN_WEIGHT = 512;
1022 * Cached subtree weight from {@link #getTransformWeight}, valid for
1023 * {@link #subtreeWeightCycle} only.
1025 private int cachedSubtreeWeight;
1028 * Transform cycle id the cached subtree weight was computed on.
1029 * Keyed on the globally unique cycle id, not the per-context frame
1030 * number, so alternating between rendering contexts cannot produce
1033 private long subtreeWeightCycle = -1;
1036 * Transform cycle id the cached subtree weight was last RECOMPUTED on.
1037 * Separate from {@link #subtreeWeightCycle} (last read): the refresh
1038 * gate measures the age of the computation, not of the last access.
1040 private long subtreeWeightComputeCycle = -1;
1043 * {@link #renderListVersion} at the last weight recomputation.
1045 private int weightListVersion = -1;
1048 * Bumped whenever ANY composite rebuilds its render list. Lets the
1049 * weight shortcut react to structural changes anywhere in the tree
1050 * within one cycle, at O(1) per node per cycle — scanning direct
1051 * children's versions instead costs O(leaves) per frame because leaf
1052 * lists dominate (measured 2026-09-04: +3-4 ms/frame on a 400-sphere
1055 private static final AtomicInteger globalRenderListVersion = new AtomicInteger();
1058 * {@link #globalRenderListVersion} value seen at the last weight
1061 private int weightGlobalVersion = -1;
1064 * Bumped every time {@link #cachedRenderList} is rebuilt. Gates weight
1065 * recomputation: while the list is unchanged, the cached weight is
1066 * reused without re-walking the subtree.
1068 private int renderListVersion;
1071 * Full subtree weight re-walks are O(total leaves below this node),
1072 * which costs real milliseconds on big meshes. With an unchanged render
1073 * list the cached weight is refreshed at most every this many cycles;
1074 * structural changes (rebuilds) recompute immediately.
1076 private static final long WEIGHT_REFRESH_CYCLES = 16;
1079 * Total transform weight of this composite: the sum of its children's
1080 * weights, i.e. roughly the number of leaf primitives below it.
1081 * Computed lazily; recomputed only when this node's render list was
1082 * rebuilt or the cache is older than {@link #WEIGHT_REFRESH_CYCLES}
1083 * cycles (children's internal rebuilds are picked up by the periodic
1084 * refresh). Used solely for parallel fork load balancing, never for
1085 * correctness, so brief staleness is harmless.
1087 * <p>Thread safety: a composite's fork decision runs on exactly one
1088 * thread per cycle. Chunk-thread reads are cycle-stamped cache hits
1089 * published through the executor's happens-before edge.</p>
1091 * @param renderingContext the rendering context (cycle identity)
1092 * @return subtree transform weight, at least 1
1095 public int getTransformWeight(final RenderingContext renderingContext) {
1096 final long cycle = renderingContext.transformCycleId;
1097 if (subtreeWeightCycle == cycle) {
1098 return cachedSubtreeWeight;
1100 final int globalVersion = globalRenderListVersion.get();
1101 if (weightListVersion == renderListVersion
1102 && weightGlobalVersion == globalVersion
1103 && cycle - subtreeWeightComputeCycle < WEIGHT_REFRESH_CYCLES) {
1104 // Nothing rebuilt anywhere and computation fresh: keep the
1105 // value, just re-stamp the read. O(1) per node per cycle.
1106 subtreeWeightCycle = cycle;
1107 return cachedSubtreeWeight;
1110 for (final AbstractShape child : cachedRenderList) {
1111 weight += child.getTransformWeight(renderingContext);
1113 cachedSubtreeWeight = Math.max(1, weight);
1114 subtreeWeightCycle = cycle;
1115 subtreeWeightComputeCycle = cycle;
1116 weightListVersion = renderListVersion;
1117 weightGlobalVersion = globalVersion;
1118 return cachedSubtreeWeight;
1122 * Decides whether this composite forks its children's transform into
1123 * parallel chunks: enough children to split, and enough TOTAL weight
1124 * below it to amortize the fork overhead. Weight (not local child
1125 * count) is what matters: a deep narrow tree with two heavy children
1126 * forks just like a flat mesh with thousands of leaves.
1128 * @param context the rendering context (provides the coordinator)
1129 * @return true when the parallel fork should be taken
1131 private boolean shouldForkTransform(final RenderingContext context) {
1132 if (context.transformCoordinator == null) {
1135 if (cachedRenderList.size() < PARALLEL_TRANSFORM_MIN_SHAPES) {
1138 return getTransformWeight(context) >= PARALLEL_TRANSFORM_MIN_SUBTREE_WEIGHT;
1142 * Target number of task chunks per available processor core.
1143 * More tasks than cores gives the pool load balancing across
1144 * shapes with uneven transform cost.
1146 private static final int PARALLEL_TASKS_PER_CORE = 4;
1149 * Transforms all children serially on the calling thread.
1151 * @param transformPipe the transform stack (includes this composite's transform)
1152 * @param aggregator the aggregator to queue visible shapes into
1153 * @param context the rendering context
1155 private void transformChildrenSerial(final TransformStack transformPipe,
1156 final RenderAggregator aggregator,
1157 final RenderingContext context) {
1158 for (final AbstractShape shape : cachedRenderList) {
1159 shape.transform(transformPipe, aggregator, context);
1164 * Forks the children's transform into parallel chunk tasks on the
1165 * frame's {@link ParallelTransformCoordinator} and returns immediately
1166 * WITHOUT waiting for them.
1168 * <p>Works at any nesting level: a heavy composite reached inside a
1169 * chunk task forks its own children into the same coordinator. This is
1170 * deadlock-safe because chunk tasks never block on other tasks; only
1171 * the orchestrating render thread waits (in the coordinator's drain).</p>
1173 * <p>Stack snapshotting: this composite's transform is dropped from
1174 * {@code transformPipe} right after this method returns, long before
1175 * the chunk tasks run, so the pipe is copied HERE on the forking
1176 * thread. Each task then copies the snapshot for its own working stack.
1177 * The snapshot is never mutated after publication, so concurrent
1178 * copying by chunk tasks is safe.</p>
1180 * <p>Thread-safety notes: sibling composites are exclusively owned by
1181 * one chunk, so their per-instance caches (render list, bounding
1182 * boxes, own transform's cached matrix) never race. The vertex
1183 * frameNumber cache is a benign race: every thread writes the same
1186 * @param transformPipe the transform stack (includes this composite's transform)
1187 * @param aggregator unused in the parallel path: per-task aggregators
1188 * are merged by the coordinator's drain
1189 * @param context the rendering context (provides the coordinator)
1191 private void transformChildrenParallel(final TransformStack transformPipe,
1192 final RenderAggregator aggregator,
1193 final RenderingContext context) {
1194 // Snapshot the render list reference. With the pipelined render
1195 // loop, the NEXT pass's tree walk can already be running while
1196 // this pass's chunk tasks are still queued (the drain happens in
1197 // the async continuation, not before the next walk). That walk
1198 // may rebuild this composite's render list, REASSIGNING
1199 // cachedRenderList to a new list of a different size. The chunk
1200 // ranges below are computed against this list instance, so the
1201 // chunk tasks must index this same instance — re-reading the
1202 // field inside the lambda raced with the rebuild and threw
1203 // IndexOutOfBoundsException. (The old list stays alive and valid
1204 // for this pass; each pass transforms into its own vertex slot.)
1205 final List<AbstractShape> renderList = cachedRenderList;
1206 final int size = renderList.size();
1207 final int totalWeight = getTransformWeight(context);
1208 final int processors = Runtime.getRuntime().availableProcessors();
1209 final int targetTasks = processors * PARALLEL_TASKS_PER_CORE;
1210 final int taskWeight = Math.max(PARALLEL_TASK_MIN_WEIGHT,
1211 (totalWeight + targetTasks - 1) / targetTasks);
1213 // Pass 1: count chunks, cutting by ACCUMULATED WEIGHT so that a
1214 // node with few but heavy children (e.g. two 25k-triangle halves
1215 // of a fractal) still splits into multiple tasks
1217 int accumulated = 0;
1218 for (int i = 0; i < size; i++) {
1219 accumulated += renderList.get(i).getTransformWeight(context);
1220 if (accumulated >= taskWeight) {
1225 if (accumulated > 0) {
1229 if (taskCount < 2) {
1230 transformChildrenSerial(transformPipe, aggregator, context);
1234 final ParallelTransformCoordinator coordinator = context.transformCoordinator;
1235 if (!coordinator.tryReserveTasks(taskCount)) {
1236 // Frame-wide task budget exhausted: transform inline
1237 transformChildrenSerial(transformPipe, aggregator, context);
1241 final TransformStack snapshot = new TransformStack(transformPipe);
1243 // Pass 2: submit chunks (weights are frame-cached, cheap re-walk)
1246 for (int i = 0; i < size; i++) {
1247 accumulated += renderList.get(i).getTransformWeight(context);
1248 if (accumulated >= taskWeight || i == size - 1) {
1249 final int chunkFrom = from;
1250 final int chunkTo = i + 1;
1251 coordinator.submit(() -> {
1252 // Pooled scratch: the stack (9.6 KB of arrays) and
1253 // the chunk aggregator (queue keeps its capacity
1254 // across frames) come from the coordinator's static
1255 // pools — previously each chunk allocated both on
1257 final TransformStack taskStack =
1258 coordinator.borrowStack(snapshot);
1260 final RenderAggregator taskAggregator =
1261 coordinator.borrowAggregator();
1262 for (int c = chunkFrom; c < chunkTo; c++) {
1263 renderList.get(c).transform(taskStack, taskAggregator, context);
1265 return taskAggregator;
1267 coordinator.returnStack(taskStack);
1277 * Transforms a point to view space using the current transform stack.
1278 * Helper method for frustum culling that transforms bounding box corners.
1280 * @param x the X coordinate in local space
1281 * @param y the Y coordinate in local space
1282 * @param z the Z coordinate in local space
1283 * @param transformPipe the current transform stack
1284 * @return the transformed point in view space
1286 private Point3D transformPointToViewSpace(final double x, final double y, final double z,
1287 final TransformStack transformPipe) {
1288 final Point3D input = new Point3D(x, y, z);
1289 final Point3D result = new Point3D();
1290 transformPipe.transform(input, result);