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.geometry.BspTree;
9 import eu.svjatoslav.aukio.e3d.geometry.Frustum;
10 import eu.svjatoslav.aukio.e3d.geometry.Point3D;
11 import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
12 import eu.svjatoslav.aukio.e3d.gui.ViewSpaceTracker;
13 import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController;
14 import eu.svjatoslav.aukio.e3d.math.Transform;
15 import eu.svjatoslav.aukio.e3d.math.TransformStack;
16 import eu.svjatoslav.aukio.e3d.math.Vertex;
17 import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
18 import eu.svjatoslav.aukio.e3d.renderer.raster.ParallelTransformCoordinator;
19 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
20 import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator;
21 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape;
22 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line;
23 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
24 import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle;
26 import java.util.ArrayList;
27 import java.util.Iterator;
28 import java.util.List;
29 import java.util.concurrent.atomic.AtomicInteger;
32 * A composite shape that groups multiple sub-shapes into a single logical unit.
34 * <p>Use {@code AbstractCompositeShape} to build complex 3D objects by combining
35 * primitive shapes (lines, polygons, textured polygons) into a group that can be
36 * positioned, rotated, and manipulated as one entity. Sub-shapes can be organized
37 * into named groups for selective visibility toggling.</p>
39 * <p><b>Usage example - creating a custom composite shape:</b></p>
41 * // Create a composite shape at position (0, 0, 200)
42 * AbstractCompositeShape myObject = new AbstractCompositeShape(
43 * new Point3D(0, 0, 200)
47 * myObject.addShape(new Line(
48 * new Point3D(-50, 0, 0), new Point3D(50, 0, 0),
52 * // Add shapes to a named group for toggling visibility
53 * myObject.addShape(labelShape, "labels");
54 * myObject.hideGroup("labels"); // hide all shapes in "labels" group
55 * myObject.showGroup("labels"); // show them again
58 * viewPanel.getRootShapeCollection().addShape(myObject);
61 * <p><b>Perspective-correct texturing:</b></p>
62 * <p>Textured polygons are rendered with Quake-style perspective-correct scanline
63 * mapping ({@code TexturedTriangle}), so no screen-size tessellation is needed.</p>
65 * <p><b>Extending this class:</b></p>
66 * <p>Override {@link #beforeTransformHook} to customize shape appearance or behavior
67 * on each frame (e.g., animations, dynamic geometry updates).</p>
69 * @see SubShape wrapper for individual sub-shapes with group and visibility support
70 * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape the base shape class
72 public class AbstractCompositeShape extends AbstractShape {
74 * Source-of-truth registry of all sub-shapes added to this composite.
76 * <p>Each sub-shape is wrapped with its group identifier and visibility state.
77 * Shapes are stored in insertion order and remain in this collection even when
78 * hidden (visibility state toggles instead of removal).</p>
80 * <p><b>Performance note:</b> This list is NOT processed for every frame.
81 * Instead, it serves as the authoritative source from which {@link #cachedRenderList}
82 * is compiled whenever the cache becomes invalid (see {@link #cacheNeedsRebuild}).
83 * Only modifications to this registry (add/remove/show/hide) trigger cache rebuild.</p>
85 * @see #cachedRenderList the frame-optimized cache derived from this registry
86 * @see #cacheNeedsRebuild the flag controlling when the cache is rebuilt
88 private final List<SubShape> subShapesRegistry = new ArrayList<>();
91 * Tracks the distance and angle between the camera and this shape.
92 * Used e.g. by TextCanvas for distance-based rendering mode selection.
94 private final ViewSpaceTracker viewSpaceTracker;
97 * Frame-optimized cache of shapes ready for rendering, derived from {@link #subShapesRegistry}.
99 * <p>This list is processed during every frame in the {@link #transform} method.
102 * <li>Shapes passing through directly (Line, TexturedTriangle, ...)</li>
103 * <li>Solid polygons with more than 3 vertices - fan-triangulated</li>
106 * <p><b>Caching strategy:</b> The list is rebuilt only when
107 * {@link #cacheNeedsRebuild} is true, avoiding per-frame reconstruction
110 * @see #subShapesRegistry the source registry this cache is derived from
111 * @see #cacheNeedsRebuild the flag that triggers cache regeneration
113 private List<AbstractShape> cachedRenderList = new ArrayList<>();
116 * Flag indicating whether {@link #cachedRenderList} needs to be rebuilt from {@link #subShapesRegistry}.
118 * <p>Set to {@code true} when:</p>
120 * <li>A shape is added via {@link #addShape}</li>
121 * <li>A shape is removed via {@link #removeGroup}</li>
122 * <li>Group visibility changes via {@link #showGroup} or {@link #hideGroup}</li>
125 * <p>Set to {@code false} after {@link #rebuildRenderList} completes the cache rebuild.</p>
127 * <p>This flag enables the performance optimization of avoiding per-frame list
128 * reconstruction - the registry is only re-processed when something actually changed.</p>
130 * @see #subShapesRegistry the source data that may need reprocessing
131 * @see #cachedRenderList the cache that gets rebuilt when this flag is true
133 private boolean cacheNeedsRebuild = true;
136 * Flag indicating this composite is the root scene container (ShapeCollection's root).
138 * <p>Set via {@link #setRootComposite(boolean)} by ShapeCollection.</p>
140 private boolean isRootComposite = false;
143 * The position and orientation transform for this composite shape.
144 * Applied to all sub-shapes during the rendering transform pass.
146 private Transform transform;
149 * Creates a composite shape at the world origin with no rotation.
151 public AbstractCompositeShape() {
152 this(new Transform());
156 * Creates a composite shape at the specified location with no rotation.
158 * @param location the position in world space
160 public AbstractCompositeShape(final Point3D location) {
161 this(new Transform(location));
165 * Creates a composite shape with the specified transform (position and orientation).
167 * @param transform the initial transform defining position and rotation
169 public AbstractCompositeShape(final Transform transform) {
170 this.transform = transform;
171 viewSpaceTracker = new ViewSpaceTracker();
175 * Adds a sub-shape to this composite shape without a group identifier.
177 * @param shape the shape to add
179 public void addShape(final AbstractShape shape) {
180 addShape(shape, null);
184 * Adds a sub-shape to this composite shape with an optional group identifier.
186 * <p>Grouped shapes can be shown, hidden, or removed together using
187 * {@link #showGroup}, {@link #hideGroup}, and {@link #removeGroup}.</p>
189 * @param shape the shape to add
190 * @param groupId the group identifier, or {@code null} for ungrouped shapes
192 public void addShape(final AbstractShape shape, final String groupId) {
193 subShapesRegistry.add(new SubShape(shape, groupId, true));
194 cacheNeedsRebuild = true;
198 * This method should be overridden by anyone wanting to customize the shape
199 * before it is rendered.
201 * @param transformPipe the current transform stack
202 * @param context the rendering context for the current frame
204 public void beforeTransformHook(final TransformStack transformPipe,
205 final RenderingContext context) {
209 * Returns the world-space position of this composite shape.
211 * @return the translation component of this shape's transform
213 public Point3D getLocation() {
214 return transform.getTranslation();
218 * Returns the axis-aligned bounding box encompassing all sub-shapes.
220 * <p>The bounding box is computed by aggregating the bounds of all visible
221 * sub-shapes, then transforming the result by this composite's own transform.</p>
223 * <p><b>Caching:</b> The bounding box is recomputed whenever
224 * {@link #cacheNeedsRebuild} is true (shapes added/removed/visibility changed).
225 * For nested composites, the bounds include their local transform offset.</p>
227 * @return the axis-aligned bounding box in this composite's local coordinates
230 public Box getBoundingBox() {
231 if (cachedBoundingBox == null || cacheNeedsRebuild) {
232 if (subShapesRegistry.isEmpty()) {
233 return super.getBoundingBox();
236 double minX = Double.MAX_VALUE;
237 double maxX = -Double.MAX_VALUE;
238 double minY = Double.MAX_VALUE;
239 double maxY = -Double.MAX_VALUE;
240 double minZ = Double.MAX_VALUE;
241 double maxZ = -Double.MAX_VALUE;
243 for (final SubShape subShape : subShapesRegistry) {
244 if (!subShape.isVisible()) {
248 final AbstractShape shape = subShape.getShape();
249 final Box shapeBounds = shape.getBoundingBox();
251 // Get bounds and apply sub-shape's transform if it's a composite
252 Point3D shapeMin = new Point3D(shapeBounds.getMinX(), shapeBounds.getMinY(), shapeBounds.getMinZ());
253 Point3D shapeMax = new Point3D(shapeBounds.getMaxX(), shapeBounds.getMaxY(), shapeBounds.getMaxZ());
254 if (shape instanceof AbstractCompositeShape) {
255 final Transform subTransform = ((AbstractCompositeShape) shape).getTransform();
256 final Point3D subTranslation = subTransform.getTranslation();
257 shapeMin.add(subTranslation);
258 shapeMax.add(subTranslation);
261 minX = Math.min(minX, shapeMin.x);
262 maxX = Math.max(maxX, shapeMax.x);
263 minY = Math.min(minY, shapeMin.y);
264 maxY = Math.max(maxY, shapeMax.y);
265 minZ = Math.min(minZ, shapeMin.z);
266 maxZ = Math.max(maxZ, shapeMax.z);
269 if (minX == Double.MAX_VALUE) {
271 return super.getBoundingBox();
274 cachedBoundingBox = new Box(
275 new Point3D(minX, minY, minZ),
276 new Point3D(maxX, maxY, maxZ)
279 return cachedBoundingBox;
283 * Returns the sub-shapes registry (source of truth for all sub-shapes).
285 * <p>This is the authoritative list of all sub-shapes including hidden ones.
286 * For per-frame rendering, use {@link #cachedRenderList} instead (accessed internally).</p>
288 * @return the registry list of all sub-shapes with their group and visibility metadata
289 * @see #cachedRenderList the frame-optimized cache derived from this registry
291 public List<SubShape> getSubShapesRegistry() {
292 return subShapesRegistry;
296 * Extracts all SolidPolygon instances from this composite shape.
298 * <p>Recursively traverses the shape hierarchy and collects all
299 * SolidPolygon instances. Used for CSG operations where polygons
300 * are needed directly without conversion.</p>
302 * @return list of SolidPolygon instances from this shape hierarchy
304 public List<SolidPolygon> extractSolidPolygons() {
305 final List<SolidPolygon> result = new ArrayList<>();
306 for (final SubShape subShape : subShapesRegistry) {
307 final AbstractShape shape = subShape.getShape();
308 if (shape instanceof SolidPolygon) {
309 result.add((SolidPolygon) shape);
310 } else if (shape instanceof AbstractCompositeShape) {
311 result.addAll(((AbstractCompositeShape) shape).extractSolidPolygons());
318 * Returns the view-space tracker that monitors the distance
319 * and angle between the camera and this shape for level-of-detail adjustments.
321 * @return the view-space tracker for this shape
323 public ViewSpaceTracker getViewSpaceTracker() {
324 return viewSpaceTracker;
328 * Hides all sub-shapes belonging to the specified group.
329 * Hidden shapes are not rendered but remain in the collection.
331 * @param groupIdentifier the group to hide
332 * @see #showGroup(String)
333 * @see #removeGroup(String)
335 public void hideGroup(final String groupIdentifier) {
336 for (final SubShape subShape : subShapesRegistry) {
337 if (subShape.matchesGroup(groupIdentifier)) {
338 subShape.setVisible(false);
339 cacheNeedsRebuild = true;
345 * Permanently removes all sub-shapes belonging to the specified group.
347 * @param groupIdentifier the group to remove
348 * @see #hideGroup(String)
350 public void removeGroup(final String groupIdentifier) {
351 final java.util.Iterator<SubShape> iterator = subShapesRegistry
354 while (iterator.hasNext()) {
355 final SubShape subShape = iterator.next();
356 if (subShape.matchesGroup(groupIdentifier)) {
358 cacheNeedsRebuild = true;
364 * Returns all sub-shapes belonging to the specified group.
366 * @param groupIdentifier the group identifier to match
367 * @return list of matching sub-shapes
369 public List<SubShape> getGroup(final String groupIdentifier) {
370 final List<SubShape> result = new ArrayList<>();
371 for (int i = 0; i < subShapesRegistry.size(); i++) {
372 final SubShape subShape = subShapesRegistry.get(i);
373 if (subShape.matchesGroup(groupIdentifier))
374 result.add(subShape);
380 * Rebuilds the cached render list if shapes were added, removed, or
381 * visibility changed since the last rebuild.
383 * @param context the rendering context for logging
385 private void rebuildRenderListIfNeeded(final RenderingContext context) {
386 if (cacheNeedsRebuild)
387 rebuildRenderList(context);
391 * Paint solid elements of this composite shape into given color.
393 * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
395 * @param color the color to apply to all solid sub-shapes
397 public void setColor(final Color color) {
398 for (final SubShape subShape : getSubShapesRegistry()) {
399 final AbstractShape shape = subShape.getShape();
401 if (shape instanceof SolidPolygon) {
402 ((SolidPolygon) shape).setColor(color);
403 } else if (shape instanceof Line) {
404 ((Line) shape).color = color;
405 } else if (shape instanceof AbstractCompositeShape) {
406 ((AbstractCompositeShape) shape).setColor(color);
412 * Assigns a group identifier to all sub-shapes that currently have no group.
414 * @param groupIdentifier the group to assign to ungrouped shapes
416 public void setGroupForUngrouped(final String groupIdentifier) {
417 for (final SubShape subShape : subShapesRegistry)
418 if (subShape.isUngrouped())
419 subShape.setGroup(groupIdentifier);
423 public void setMouseInteractionController(
424 final MouseInteractionController mouseInteractionController) {
425 super.setMouseInteractionController(mouseInteractionController);
427 for (final SubShape subShape : subShapesRegistry)
428 subShape.getShape().setMouseInteractionController(
429 mouseInteractionController);
431 cacheNeedsRebuild = true;
435 * Marks this composite as the root scene container.
437 * <p>Called by {@code ShapeCollection} to configure its root composite.</p>
439 * @param isRoot {@code true} if this is the root composite, {@code false} otherwise
441 public void setRootComposite(final boolean isRoot) {
442 this.isRootComposite = isRoot;
446 * Returns this composite's transform (position and orientation).
448 * @return the transform object
450 public Transform getTransform() {
455 * Sets the transform for this composite shape.
457 * @param transform the new transform
458 * @return this composite shape (for chaining)
460 public AbstractCompositeShape setTransform(final Transform transform) {
461 this.transform = transform;
466 * Sets the cache rebuild flag on this composite and all nested composites recursively.
468 * <p>Used by {@code ShapeCollection} to trigger a render-list rebuild when
469 * clearing the scene or for other advanced use cases.</p>
471 * @param needsRebuild {@code true} to force cache rebuild on next frame
473 public void setCacheNeedsRebuild(final boolean needsRebuild) {
474 this.cacheNeedsRebuild = needsRebuild;
475 // Propagate to nested composites
476 for (final SubShape subShape : subShapesRegistry) {
477 final AbstractShape shape = subShape.getShape();
478 if (shape instanceof AbstractCompositeShape composite) {
479 composite.setCacheNeedsRebuild(needsRebuild);
485 * Enables or disables shading for all SolidTriangle and SolidPolygon sub-shapes.
486 * When enabled, shapes use the global lighting manager from the rendering
487 * context to calculate flat shading based on light sources.
489 * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
491 * @param shadingEnabled {@code true} to enable shading, {@code false} to disable
492 * @return this composite shape (for chaining)
494 public AbstractCompositeShape setShadingEnabled(final boolean shadingEnabled) {
495 for (final SubShape subShape : getSubShapesRegistry()) {
496 final AbstractShape shape = subShape.getShape();
497 if (shape instanceof SolidPolygon) {
498 ((SolidPolygon) shape).setShadingEnabled(shadingEnabled);
499 } else if (shape instanceof AbstractCompositeShape) {
500 ((AbstractCompositeShape) shape).setShadingEnabled(shadingEnabled);
507 * Enables or disables backface culling for all SolidPolygon and TexturedTriangle sub-shapes.
509 * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
511 * @param backfaceCulling {@code true} to enable backface culling, {@code false} to disable
512 * @return this composite shape (for chaining)
514 public AbstractCompositeShape setBackfaceCulling(final boolean backfaceCulling) {
515 for (final SubShape subShape : getSubShapesRegistry()) {
516 final AbstractShape shape = subShape.getShape();
517 if (shape instanceof SolidPolygon) {
518 ((SolidPolygon) shape).setBackfaceCulling(backfaceCulling);
519 } else if (shape instanceof TexturedTriangle) {
520 ((TexturedTriangle) shape).setBackfaceCulling(backfaceCulling);
521 } else if (shape instanceof AbstractCompositeShape) {
522 ((AbstractCompositeShape) shape).setBackfaceCulling(backfaceCulling);
529 * Performs an in-place union with another composite shape.
531 * <p>This shape's SolidPolygon children are replaced with the union result.
532 * Non-SolidPolygon children from both shapes are preserved and combined.</p>
534 * <p><b>CSG Operation:</b> Union combines two shapes into one, keeping all
535 * geometry from both. Uses BSP tree algorithms for robust boolean operations.</p>
537 * <p><b>Child handling:</b></p>
539 * <li>SolidPolygon children from both shapes → replaced with union result</li>
540 * <li>Non-SolidPolygon children from this shape → preserved</li>
541 * <li>Non-SolidPolygon children from other shape → added to this shape</li>
542 * <li>Nested AbstractCompositeShape children → preserved unchanged (not recursively processed)</li>
545 * @param other the shape to union with
546 * @see #subtract(AbstractCompositeShape)
547 * @see #intersect(AbstractCompositeShape)
549 public void union(final AbstractCompositeShape other) {
551 final BspTree selfTree = new BspTree(clonePolygons(extractSolidPolygons()));
552 final BspTree otherTree = new BspTree(clonePolygons(other.extractSolidPolygons()));
554 // Remove from self any polygons that are inside other (interior faces)
555 selfTree.clipTo(otherTree);
557 // Remove from other any polygons that are inside self (interior faces)
558 otherTree.clipTo(selfTree);
560 // Invert other to convert remaining polygons for the next clip step
563 // Clip inverted other against self to remove back-facing coplanar polygons
564 otherTree.clipTo(selfTree);
566 // Invert back to restore correct polygon orientation
569 // Merge other's remaining polygons into self's BSP tree
570 selfTree.addPolygons(otherTree.allPolygons());
572 replaceSolidPolygons(selfTree.allPolygons());
573 mergeNonPolygonChildrenFrom(other);
577 * Performs an in-place subtraction with another composite shape.
579 * <p>This shape's SolidPolygon children are replaced with the difference result.
580 * The other shape acts as a "cutter" that carves out volume from this shape.</p>
582 * <p><b>CSG Operation:</b> Subtract removes the volume of the second shape
583 * from the first shape. Useful for creating holes, cavities, and cutouts.</p>
585 * <p><b>Child handling:</b></p>
587 * <li>SolidPolygon children from this shape → replaced with difference result</li>
588 * <li>Non-SolidPolygon children from this shape → preserved</li>
589 * <li>All children from other shape → discarded (other is just a cutter)</li>
590 * <li>Nested AbstractCompositeShape children → preserved unchanged</li>
593 * @param other the shape to subtract (the cutter)
594 * @see #union(AbstractCompositeShape)
595 * @see #intersect(AbstractCompositeShape)
597 public void subtract(final AbstractCompositeShape other) {
599 final BspTree target = new BspTree(clonePolygons(extractSolidPolygons()));
600 final BspTree cutter = new BspTree(clonePolygons(other.extractSolidPolygons()));
602 // Invert target: convert "inside" to "outside" and vice versa
603 // This transforms the problem from "subtract B from A" to "intersect A's complement with B's complement"
606 // Clip target against cutter: removes parts of target that are INSIDE the cutter
607 // Since target is inverted, this removes parts that were OUTSIDE the original target
608 target.clipTo(cutter);
610 // Clip cutter against (inverted) target: removes parts of cutter outside the inverted target
611 // This keeps only cutter polygons that are inside the inverted target = outside original target
612 cutter.clipTo(target);
614 // Invert cutter to flip its inside/outside
617 // Clip inverted cutter against target: removes coplanar back-faces
618 cutter.clipTo(target);
620 // Invert cutter back to correct orientation
623 // Merge cutter's polygons into target's BSP tree
624 target.addPolygons(cutter.allPolygons());
626 // Invert target back to restore correct inside/outside orientation
627 // Result: the carved-out volume (target minus cutter)
630 replaceSolidPolygons(target.allPolygons());
634 * Performs an in-place intersection with another composite shape.
636 * <p>This shape's SolidPolygon children are replaced with the intersection result.
637 * Only the overlapping volume between the two shapes remains.</p>
639 * <p><b>CSG Operation:</b> Intersect keeps only the volume where both shapes
640 * overlap. Useful for creating shapes constrained by multiple boundaries.</p>
642 * <p><b>Child handling:</b></p>
644 * <li>SolidPolygon children from this shape → replaced with intersection result</li>
645 * <li>Non-SolidPolygon children from this shape → preserved</li>
646 * <li>All children from other shape → discarded</li>
647 * <li>Nested AbstractCompositeShape children → preserved unchanged</li>
650 * @param other the shape to intersect with
651 * @see #union(AbstractCompositeShape)
652 * @see #subtract(AbstractCompositeShape)
654 public void intersect(final AbstractCompositeShape other) {
656 final BspTree selfTree = new BspTree(clonePolygons(extractSolidPolygons()));
657 final BspTree otherTree = new BspTree(clonePolygons(other.extractSolidPolygons()));
659 // Invert self to convert "inside" to "outside"
660 // This transforms intersection into: keep parts that are "outside both inverted shapes"
663 // Clip other against inverted self: keeps only parts of other that are INSIDE original self
664 // (because clipTo removes what's "outside" the BSP, and inverted self's "outside" = original self's "inside")
665 otherTree.clipTo(selfTree);
667 // Invert other (which now represents the intersection region)
670 // Clip inverted self against (inverted intersection): removes parts outside the intersection
671 selfTree.clipTo(otherTree);
673 // Clip intersection result against inverted self: removes back-facing coplanar polygons
674 otherTree.clipTo(selfTree);
676 // Build final BSP tree from the clipped intersection polygons
677 selfTree.addPolygons(otherTree.allPolygons());
679 // Invert back to restore correct inside/outside orientation
682 replaceSolidPolygons(selfTree.allPolygons());
686 * Creates deep clones of all polygons in the list.
688 * <p>CSG operations modify polygons in-place via BSP tree operations.
689 * Cloning ensures the original polygon data is preserved.</p>
691 * @param polygons the polygons to clone
692 * @return a new list containing deep clones of all polygons
694 private List<SolidPolygon> clonePolygons(final List<SolidPolygon> polygons) {
695 final List<SolidPolygon> cloned = new ArrayList<>(polygons.size());
696 for (final SolidPolygon p : polygons) {
697 cloned.add(p.deepClone());
703 * Replaces this shape's SolidPolygon children with new polygons.
705 * <p>Preserves all non-SolidPolygon children (Lines, nested composites, etc.).</p>
707 * @param newPolygons the polygons to replace with
709 private void replaceSolidPolygons(final List<SolidPolygon> newPolygons) {
710 // Remove all direct SolidPolygon children from this shape
711 final Iterator<SubShape> iterator = subShapesRegistry.iterator();
712 while (iterator.hasNext()) {
713 final SubShape subShape = iterator.next();
714 if (subShape.getShape() instanceof SolidPolygon) {
719 // Add all result polygons as new children
720 for (final SolidPolygon polygon : newPolygons) {
724 cacheNeedsRebuild = true;
728 * Merges non-SolidPolygon children from another shape into this shape.
730 * <p>Copies all non-SolidPolygon children (Lines, nested composites, etc.)
731 * from the other shape, preserving their group identifiers.</p>
733 * @param other the shape to merge non-polygon children from
735 private void mergeNonPolygonChildrenFrom(final AbstractCompositeShape other) {
740 for (final SubShape otherSubShape : other.subShapesRegistry) {
741 final AbstractShape otherShape = otherSubShape.getShape();
742 if (!(otherShape instanceof SolidPolygon)) {
743 addShape(otherShape, otherSubShape.getGroupIdentifier());
747 cacheNeedsRebuild = true;
751 * Makes all sub-shapes belonging to the specified group visible.
753 * @param groupIdentifier the group to show
754 * @see #hideGroup(String)
756 public void showGroup(final String groupIdentifier) {
757 for (int i = 0; i < subShapesRegistry.size(); i++) {
758 final SubShape subShape = subShapesRegistry.get(i);
759 if (subShape.matchesGroup(groupIdentifier)) {
760 subShape.setVisible(true);
761 cacheNeedsRebuild = true;
767 * Rebuilds the cached render list from the shape registry:
768 * textured triangles pass through as-is (perspective-correct scanline
769 * rendering needs no tessellation), N-vertex solid polygons are
770 * fan-triangulated, everything else passes through.
771 * Logs the operation to the debug log buffer if available.
773 * @param context the rendering context for logging, may be {@code null}
775 private void rebuildRenderList(final RenderingContext context) {
776 cacheNeedsRebuild = false;
778 final List<AbstractShape> result = new ArrayList<>();
779 int texturedPolygonCount = 0;
780 int solidPolygonCount = 0;
781 int triangulatedPolygonCount = 0;
782 int otherShapeCount = 0;
784 for (int i = 0; i < subShapesRegistry.size(); i++) {
785 final SubShape subShape = subShapesRegistry.get(i);
786 if (!subShape.isVisible())
789 final AbstractShape shape = subShape.getShape();
791 if (shape instanceof TexturedTriangle) {
793 texturedPolygonCount++;
794 } else if (shape instanceof SolidPolygon polygon) {
795 final int vertexCount = polygon.getVertexCount();
797 if (vertexCount == 3) {
801 triangulateSolidPolygon(polygon, result);
802 triangulatedPolygonCount++;
810 cachedRenderList = postprocessRenderList(result);
812 globalRenderListVersion.incrementAndGet();
814 if (context != null && context.debugLogBuffer != null) {
815 context.debugLogBuffer.log("rebuildRenderList: " + getClass().getSimpleName()
816 + " texturedPolygons=" + texturedPolygonCount
817 + " solidPolygons=" + solidPolygonCount
818 + " triangulatedPolygons=" + triangulatedPolygonCount
819 + " otherShapes=" + otherShapeCount);
824 * Returns the global render list version: incremented every time ANY
825 * composite's render list is rebuilt. Used by derived structures
826 * (BSP trees, GI scene snapshots) to detect that they must rebuild.
828 * @return monotonically increasing global version
830 public static int getGlobalRenderListVersion() {
831 return globalRenderListVersion.get();
835 * Collects the triangles of this composite's current render list,
836 * recursing into nested composites. These are the exact objects that
837 * get transformed and rendered: triangulated render-list polygons, or
838 * lightmapped wrappers for
839 * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}.
841 * <p>Render lists are built lazily during transform; composites that
842 * have not been transformed yet (or are frustum-culled) contribute
843 * nothing. Vertices are in each composite's local space — callers
844 * combining several composites should require identity transforms.</p>
846 * @param out list receiving the triangles
848 public void collectRenderTriangles(final List<AbstractCoordinateShape> out) {
849 if (cachedRenderList == null)
851 for (final AbstractShape shape : cachedRenderList) {
852 if (shape instanceof SolidPolygon
853 || shape instanceof eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle)
854 out.add((AbstractCoordinateShape) shape);
855 else if (shape instanceof AbstractCompositeShape)
856 ((AbstractCompositeShape) shape).collectRenderTriangles(out);
861 * Hook: post-processes the freshly rebuilt render list before it becomes
862 * the rendering cache. The default implementation returns the list
863 * unchanged. Subclasses may replace the list — e.g.
864 * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}
865 * wraps its polygons into lightmapped triangles here.
867 * @param renderList the render list built from the shape registry
868 * @return the render list to cache and render
870 protected List<AbstractShape> postprocessRenderList(final List<AbstractShape> renderList) {
875 * Triangulates a convex solid polygon using fan triangulation.
877 * <p>Fan triangulation creates N-2 triangles from an N-vertex polygon by using
878 * vertex 0 as the anchor and connecting it to each adjacent pair of vertices.</p>
880 * <p>Properties (color, shading, backface culling, mouse interaction) are
881 * propagated to each resulting triangle to ensure consistent behavior.</p>
883 * @param polygon the polygon to triangulate (must have at least 4 vertices)
884 * @param result the list to add the resulting triangles to
886 private void triangulateSolidPolygon(final SolidPolygon polygon,
887 final List<AbstractShape> result) {
889 final Color color = polygon.getColor();
890 final boolean shadingEnabled = polygon.isShadingEnabled();
891 final boolean backfaceCulling = polygon.isBackfaceCullingEnabled();
892 final MouseInteractionController mouseController = polygon.mouseInteractionController;
894 final List<Vertex> vertices = polygon.vertices;
895 final Vertex v0 = vertices.get(0);
897 for (int i = 1; i < vertices.size() - 1; i++) {
898 final Vertex v1 = vertices.get(i);
899 final Vertex v2 = vertices.get(i + 1);
901 final SolidPolygon triangle = new SolidPolygon(
902 v0.coordinate, v1.coordinate, v2.coordinate, color);
904 triangle.setShadingEnabled(shadingEnabled);
905 triangle.setBackfaceCulling(backfaceCulling);
906 triangle.setMouseInteractionController(mouseController);
908 result.add(triangle);
913 public void transform(final TransformStack transformPipe,
914 final RenderAggregator aggregator, final RenderingContext context) {
916 // Add the current composite shape transform to the end of the transform
918 transformPipe.addTransform(transform);
920 // FRUSTUM CULLING: Check if this composite's bounds are visible
921 // Root composite skips this check (its bounds are always the full scene)
922 // Non-root composites check their aggregated bounds against the frustum
923 if (context.frustum != null && !isRootComposite) {
924 // Count this composite for culling statistics (before frustum test)
925 if (context.cullingStatistics != null) {
926 context.cullingStatistics.totalComposites.incrementAndGet();
929 final Box localBounds = getBoundingBox();
931 // Transform all 8 corners of the bounding box to view space
932 final double minX = localBounds.getMinX();
933 final double maxX = localBounds.getMaxX();
934 final double minY = localBounds.getMinY();
935 final double maxY = localBounds.getMaxY();
936 final double minZ = localBounds.getMinZ();
937 final double maxZ = localBounds.getMaxZ();
939 final double[] xs = {minX, maxX};
940 final double[] ys = {minY, maxY};
941 final double[] zs = {minZ, maxZ};
943 double viewMinX = Double.MAX_VALUE;
944 double viewMaxX = -Double.MAX_VALUE;
945 double viewMinY = Double.MAX_VALUE;
946 double viewMaxY = -Double.MAX_VALUE;
947 double viewMinZ = Double.MAX_VALUE;
948 double viewMaxZ = -Double.MAX_VALUE;
950 for (int i = 0; i < 8; i++) {
951 final double x = xs[(i & 1)];
952 final double y = ys[(i >> 1) & 1];
953 final double z = zs[(i >> 2) & 1];
955 final Point3D corner = transformPointToViewSpace(x, y, z, transformPipe);
957 viewMinX = Math.min(viewMinX, corner.x);
958 viewMaxX = Math.max(viewMaxX, corner.x);
959 viewMinY = Math.min(viewMinY, corner.y);
960 viewMaxY = Math.max(viewMaxY, corner.y);
961 viewMinZ = Math.min(viewMinZ, corner.z);
962 viewMaxZ = Math.max(viewMaxZ, corner.z);
965 final Box viewSpaceBounds = new Box(
966 new Point3D(viewMinX, viewMinY, viewMinZ),
967 new Point3D(viewMaxX, viewMaxY, viewMaxZ)
970 final Frustum frustum = context.frustum;
971 final boolean visible = frustum.intersectsAABB(viewSpaceBounds);
974 // Entire composite outside frustum - skip processing all children
975 if (context.cullingStatistics != null) {
976 context.cullingStatistics.culledComposites.incrementAndGet();
978 transformPipe.dropTransform();
983 viewSpaceTracker.analyze(transformPipe, context);
985 beforeTransformHook(transformPipe, context);
987 rebuildRenderListIfNeeded(context);
989 // transform rendered subshapes
990 if (shouldForkTransform(context)) {
991 transformChildrenParallel(transformPipe, aggregator, context);
993 transformChildrenSerial(transformPipe, aggregator, context);
996 transformPipe.dropTransform();
1000 * Minimum number of children before the parallel fork is considered at
1001 * all. A single child cannot be split; splitting happens in the child.
1003 private static final int PARALLEL_TRANSFORM_MIN_SHAPES = 2;
1006 * Minimum total subtree weight (leaf primitives below this composite)
1007 * before forking its transform into parallel chunks. Below this, the
1008 * serial walk is cheaper than the fork overhead. Deliberately above
1009 * one sphere's generated triangle count (~960 at 16 segments):
1010 * measured 2026-09-04, forking those pays task overhead per chunk for
1011 * negligible serial work (35k chunk tasks on a 3000-sphere scene were
1012 * SLOWER than serial).
1014 private static final int PARALLEL_TRANSFORM_MIN_SUBTREE_WEIGHT = 2048;
1017 * Minimum weight per parallel chunk task. Keeps chunk granularity
1018 * coarse enough that task dispatch overhead stays negligible.
1020 private static final int PARALLEL_TASK_MIN_WEIGHT = 512;
1023 * Cached subtree weight from {@link #getTransformWeight}, valid for
1024 * {@link #subtreeWeightCycle} only.
1026 private int cachedSubtreeWeight;
1029 * Transform cycle id the cached subtree weight was computed on.
1030 * Keyed on the globally unique cycle id, not the per-context frame
1031 * number, so alternating between rendering contexts cannot produce
1034 private long subtreeWeightCycle = -1;
1037 * Transform cycle id the cached subtree weight was last RECOMPUTED on.
1038 * Separate from {@link #subtreeWeightCycle} (last read): the refresh
1039 * gate measures the age of the computation, not of the last access.
1041 private long subtreeWeightComputeCycle = -1;
1044 * {@link #renderListVersion} at the last weight recomputation.
1046 private int weightListVersion = -1;
1049 * Bumped whenever ANY composite rebuilds its render list. Lets the
1050 * weight shortcut react to structural changes anywhere in the tree
1051 * within one cycle, at O(1) per node per cycle — scanning direct
1052 * children's versions instead costs O(leaves) per frame because leaf
1053 * lists dominate (measured 2026-09-04: +3-4 ms/frame on a 400-sphere
1056 private static final AtomicInteger globalRenderListVersion = new AtomicInteger();
1059 * {@link #globalRenderListVersion} value seen at the last weight
1062 private int weightGlobalVersion = -1;
1065 * Bumped every time {@link #cachedRenderList} is rebuilt. Gates weight
1066 * recomputation: while the list is unchanged, the cached weight is
1067 * reused without re-walking the subtree.
1069 private int renderListVersion;
1072 * Full subtree weight re-walks are O(total leaves below this node),
1073 * which costs real milliseconds on big meshes. With an unchanged render
1074 * list the cached weight is refreshed at most every this many cycles;
1075 * structural changes (rebuilds) recompute immediately.
1077 private static final long WEIGHT_REFRESH_CYCLES = 16;
1080 * Total transform weight of this composite: the sum of its children's
1081 * weights, i.e. roughly the number of leaf primitives below it.
1082 * Computed lazily; recomputed only when this node's render list was
1083 * rebuilt or the cache is older than {@link #WEIGHT_REFRESH_CYCLES}
1084 * cycles (children's internal rebuilds are picked up by the periodic
1085 * refresh). Used solely for parallel fork load balancing, never for
1086 * correctness, so brief staleness is harmless.
1088 * <p>Thread safety: a composite's fork decision runs on exactly one
1089 * thread per cycle. Chunk-thread reads are cycle-stamped cache hits
1090 * published through the executor's happens-before edge.</p>
1092 * @param renderingContext the rendering context (cycle identity)
1093 * @return subtree transform weight, at least 1
1096 public int getTransformWeight(final RenderingContext renderingContext) {
1097 final long cycle = renderingContext.transformCycleId;
1098 if (subtreeWeightCycle == cycle) {
1099 return cachedSubtreeWeight;
1101 final int globalVersion = globalRenderListVersion.get();
1102 if (weightListVersion == renderListVersion
1103 && weightGlobalVersion == globalVersion
1104 && cycle - subtreeWeightComputeCycle < WEIGHT_REFRESH_CYCLES) {
1105 // Nothing rebuilt anywhere and computation fresh: keep the
1106 // value, just re-stamp the read. O(1) per node per cycle.
1107 subtreeWeightCycle = cycle;
1108 return cachedSubtreeWeight;
1111 for (final AbstractShape child : cachedRenderList) {
1112 weight += child.getTransformWeight(renderingContext);
1114 cachedSubtreeWeight = Math.max(1, weight);
1115 subtreeWeightCycle = cycle;
1116 subtreeWeightComputeCycle = cycle;
1117 weightListVersion = renderListVersion;
1118 weightGlobalVersion = globalVersion;
1119 return cachedSubtreeWeight;
1123 * Decides whether this composite forks its children's transform into
1124 * parallel chunks: enough children to split, and enough TOTAL weight
1125 * below it to amortize the fork overhead. Weight (not local child
1126 * count) is what matters: a deep narrow tree with two heavy children
1127 * forks just like a flat mesh with thousands of leaves.
1129 * @param context the rendering context (provides the coordinator)
1130 * @return true when the parallel fork should be taken
1132 private boolean shouldForkTransform(final RenderingContext context) {
1133 if (context.transformCoordinator == null) {
1136 if (cachedRenderList.size() < PARALLEL_TRANSFORM_MIN_SHAPES) {
1139 return getTransformWeight(context) >= PARALLEL_TRANSFORM_MIN_SUBTREE_WEIGHT;
1143 * Target number of task chunks per available processor core.
1144 * More tasks than cores gives the pool load balancing across
1145 * shapes with uneven transform cost.
1147 private static final int PARALLEL_TASKS_PER_CORE = 4;
1150 * Transforms all children serially on the calling thread.
1152 * @param transformPipe the transform stack (includes this composite's transform)
1153 * @param aggregator the aggregator to queue visible shapes into
1154 * @param context the rendering context
1156 private void transformChildrenSerial(final TransformStack transformPipe,
1157 final RenderAggregator aggregator,
1158 final RenderingContext context) {
1159 for (final AbstractShape shape : cachedRenderList) {
1160 shape.transform(transformPipe, aggregator, context);
1165 * Forks the children's transform into parallel chunk tasks on the
1166 * frame's {@link ParallelTransformCoordinator} and returns immediately
1167 * WITHOUT waiting for them.
1169 * <p>Works at any nesting level: a heavy composite reached inside a
1170 * chunk task forks its own children into the same coordinator. This is
1171 * deadlock-safe because chunk tasks never block on other tasks; only
1172 * the orchestrating render thread waits (in the coordinator's drain).</p>
1174 * <p>Stack snapshotting: this composite's transform is dropped from
1175 * {@code transformPipe} right after this method returns, long before
1176 * the chunk tasks run, so the pipe is copied HERE on the forking
1177 * thread. Each task then copies the snapshot for its own working stack.
1178 * The snapshot is never mutated after publication, so concurrent
1179 * copying by chunk tasks is safe.</p>
1181 * <p>Thread-safety notes: sibling composites are exclusively owned by
1182 * one chunk, so their per-instance caches (render list, bounding
1183 * boxes, own transform's cached matrix) never race. The vertex
1184 * frameNumber cache is a benign race: every thread writes the same
1187 * @param transformPipe the transform stack (includes this composite's transform)
1188 * @param aggregator unused in the parallel path: per-task aggregators
1189 * are merged by the coordinator's drain
1190 * @param context the rendering context (provides the coordinator)
1192 private void transformChildrenParallel(final TransformStack transformPipe,
1193 final RenderAggregator aggregator,
1194 final RenderingContext context) {
1195 // Snapshot the render list reference. With the pipelined render
1196 // loop, the NEXT pass's tree walk can already be running while
1197 // this pass's chunk tasks are still queued (the drain happens in
1198 // the async continuation, not before the next walk). That walk
1199 // may rebuild this composite's render list, REASSIGNING
1200 // cachedRenderList to a new list of a different size. The chunk
1201 // ranges below are computed against this list instance, so the
1202 // chunk tasks must index this same instance — re-reading the
1203 // field inside the lambda raced with the rebuild and threw
1204 // IndexOutOfBoundsException. (The old list stays alive and valid
1205 // for this pass; each pass transforms into its own vertex slot.)
1206 final List<AbstractShape> renderList = cachedRenderList;
1207 final int size = renderList.size();
1208 final int totalWeight = getTransformWeight(context);
1209 final int processors = Runtime.getRuntime().availableProcessors();
1210 final int targetTasks = processors * PARALLEL_TASKS_PER_CORE;
1211 final int taskWeight = Math.max(PARALLEL_TASK_MIN_WEIGHT,
1212 (totalWeight + targetTasks - 1) / targetTasks);
1214 // Pass 1: count chunks, cutting by ACCUMULATED WEIGHT so that a
1215 // node with few but heavy children (e.g. two 25k-triangle halves
1216 // of a fractal) still splits into multiple tasks
1218 int accumulated = 0;
1219 for (int i = 0; i < size; i++) {
1220 accumulated += renderList.get(i).getTransformWeight(context);
1221 if (accumulated >= taskWeight) {
1226 if (accumulated > 0) {
1230 if (taskCount < 2) {
1231 transformChildrenSerial(transformPipe, aggregator, context);
1235 final ParallelTransformCoordinator coordinator = context.transformCoordinator;
1236 if (!coordinator.tryReserveTasks(taskCount)) {
1237 // Frame-wide task budget exhausted: transform inline
1238 transformChildrenSerial(transformPipe, aggregator, context);
1242 final TransformStack snapshot = new TransformStack(transformPipe);
1244 // Pass 2: submit chunks (weights are frame-cached, cheap re-walk)
1247 for (int i = 0; i < size; i++) {
1248 accumulated += renderList.get(i).getTransformWeight(context);
1249 if (accumulated >= taskWeight || i == size - 1) {
1250 final int chunkFrom = from;
1251 final int chunkTo = i + 1;
1252 coordinator.submit(() -> {
1253 // Pooled scratch: the stack (9.6 KB of arrays) and
1254 // the chunk aggregator (queue keeps its capacity
1255 // across frames) come from the coordinator's static
1256 // pools — previously each chunk allocated both on
1258 final TransformStack taskStack =
1259 coordinator.borrowStack(snapshot);
1261 final RenderAggregator taskAggregator =
1262 coordinator.borrowAggregator();
1263 for (int c = chunkFrom; c < chunkTo; c++) {
1264 renderList.get(c).transform(taskStack, taskAggregator, context);
1266 return taskAggregator;
1268 coordinator.returnStack(taskStack);
1278 * Transforms a point to view space using the current transform stack.
1279 * Helper method for frustum culling that transforms bounding box corners.
1281 * @param x the X coordinate in local space
1282 * @param y the Y coordinate in local space
1283 * @param z the Z coordinate in local space
1284 * @param transformPipe the current transform stack
1285 * @return the transformed point in view space
1287 private Point3D transformPointToViewSpace(final double x, final double y, final double z,
1288 final TransformStack transformPipe) {
1289 final Point3D input = new Point3D(x, y, z);
1290 final Point3D result = new Point3D();
1291 transformPipe.transform(input, result);