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) {
549 replaceSolidPolygons(Csg.union(extractSolidPolygons(),
550 other.extractSolidPolygons()));
551 mergeNonPolygonChildrenFrom(other);
555 * Performs an in-place subtraction with another composite shape.
557 * <p>This shape's SolidPolygon children are replaced with the difference result.
558 * The other shape acts as a "cutter" that carves out volume from this shape.</p>
560 * <p><b>CSG Operation:</b> Subtract removes the volume of the second shape
561 * from the first shape. Useful for creating holes, cavities, and cutouts.</p>
563 * <p><b>Child handling:</b></p>
565 * <li>SolidPolygon children from this shape → replaced with difference result</li>
566 * <li>Non-SolidPolygon children from this shape → preserved</li>
567 * <li>All children from other shape → discarded (other is just a cutter)</li>
568 * <li>Nested AbstractCompositeShape children → preserved unchanged</li>
571 * @param other the shape to subtract (the cutter)
572 * @see #union(AbstractCompositeShape)
573 * @see #intersect(AbstractCompositeShape)
575 public void subtract(final AbstractCompositeShape other) {
576 replaceSolidPolygons(Csg.subtract(extractSolidPolygons(),
577 other.extractSolidPolygons()));
581 * Performs an in-place intersection with another composite shape.
583 * <p>This shape's SolidPolygon children are replaced with the intersection result.
584 * Only the overlapping volume between the two shapes remains.</p>
586 * <p><b>CSG Operation:</b> Intersect keeps only the volume where both shapes
587 * overlap. Useful for creating shapes constrained by multiple boundaries.</p>
589 * <p><b>Child handling:</b></p>
591 * <li>SolidPolygon children from this shape → replaced with intersection result</li>
592 * <li>Non-SolidPolygon children from this shape → preserved</li>
593 * <li>All children from other shape → discarded</li>
594 * <li>Nested AbstractCompositeShape children → preserved unchanged</li>
597 * @param other the shape to intersect with
598 * @see #union(AbstractCompositeShape)
599 * @see #subtract(AbstractCompositeShape)
601 public void intersect(final AbstractCompositeShape other) {
602 replaceSolidPolygons(Csg.intersect(extractSolidPolygons(),
603 other.extractSolidPolygons()));
607 * Replaces this shape's SolidPolygon children with new polygons.
609 * <p>Preserves all non-SolidPolygon children (Lines, nested composites, etc.).</p>
611 * @param newPolygons the polygons to replace with
613 private void replaceSolidPolygons(final List<SolidPolygon> newPolygons) {
614 // Remove all direct SolidPolygon children from this shape
615 final Iterator<SubShape> iterator = subShapesRegistry.iterator();
616 while (iterator.hasNext()) {
617 final SubShape subShape = iterator.next();
618 if (subShape.getShape() instanceof SolidPolygon) {
623 // Add all result polygons as new children
624 for (final SolidPolygon polygon : newPolygons) {
628 cacheNeedsRebuild = true;
632 * Merges non-SolidPolygon children from another shape into this shape.
634 * <p>Copies all non-SolidPolygon children (Lines, nested composites, etc.)
635 * from the other shape, preserving their group identifiers.</p>
637 * @param other the shape to merge non-polygon children from
639 private void mergeNonPolygonChildrenFrom(final AbstractCompositeShape other) {
644 for (final SubShape otherSubShape : other.subShapesRegistry) {
645 final AbstractShape otherShape = otherSubShape.getShape();
646 if (!(otherShape instanceof SolidPolygon)) {
647 addShape(otherShape, otherSubShape.getGroupIdentifier());
651 cacheNeedsRebuild = true;
655 * Makes all sub-shapes belonging to the specified group visible.
657 * @param groupIdentifier the group to show
658 * @see #hideGroup(String)
660 public void showGroup(final String groupIdentifier) {
661 for (int i = 0; i < subShapesRegistry.size(); i++) {
662 final SubShape subShape = subShapesRegistry.get(i);
663 if (subShape.matchesGroup(groupIdentifier)) {
664 subShape.setVisible(true);
665 cacheNeedsRebuild = true;
671 * Rebuilds the cached render list from the shape registry:
672 * textured triangles pass through as-is (perspective-correct scanline
673 * rendering needs no tessellation), N-vertex solid polygons are
674 * fan-triangulated, everything else passes through.
675 * Logs the operation to the debug log buffer if available.
677 * @param context the rendering context for logging, may be {@code null}
679 private void rebuildRenderList(final RenderingContext context) {
680 cacheNeedsRebuild = false;
682 final List<AbstractShape> result = new ArrayList<>();
683 int texturedPolygonCount = 0;
684 int solidPolygonCount = 0;
685 int triangulatedPolygonCount = 0;
686 int otherShapeCount = 0;
688 for (int i = 0; i < subShapesRegistry.size(); i++) {
689 final SubShape subShape = subShapesRegistry.get(i);
690 if (!subShape.isVisible())
693 final AbstractShape shape = subShape.getShape();
695 if (shape instanceof TexturedTriangle) {
697 texturedPolygonCount++;
698 } else if (shape instanceof SolidPolygon polygon) {
699 final int vertexCount = polygon.getVertexCount();
701 if (vertexCount == 3) {
705 triangulateSolidPolygon(polygon, result);
706 triangulatedPolygonCount++;
714 cachedRenderList = postprocessRenderList(result);
716 globalRenderListVersion.incrementAndGet();
718 if (context != null && context.debugLogBuffer != null) {
719 context.debugLogBuffer.log("rebuildRenderList: " + getClass().getSimpleName()
720 + " texturedPolygons=" + texturedPolygonCount
721 + " solidPolygons=" + solidPolygonCount
722 + " triangulatedPolygons=" + triangulatedPolygonCount
723 + " otherShapes=" + otherShapeCount);
728 * Returns the global render list version: incremented every time ANY
729 * composite's render list is rebuilt. Used by derived structures
730 * (BSP trees, GI scene snapshots) to detect that they must rebuild.
732 * @return monotonically increasing global version
734 public static int getGlobalRenderListVersion() {
735 return globalRenderListVersion.get();
739 * Collects the triangles of this composite's current render list,
740 * recursing into nested composites. These are the exact objects that
741 * get transformed and rendered: triangulated render-list polygons, or
742 * lightmapped wrappers for
743 * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}.
745 * <p>Render lists are built lazily during transform; composites that
746 * have not been transformed yet (or are frustum-culled) contribute
747 * nothing. Vertices are in each composite's local space — callers
748 * combining several composites should require identity transforms.</p>
750 * @param out list receiving the triangles
752 public void collectRenderTriangles(final List<AbstractCoordinateShape> out) {
753 if (cachedRenderList == null)
755 for (final AbstractShape shape : cachedRenderList) {
756 if (shape instanceof SolidPolygon
757 || shape instanceof eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle)
758 out.add((AbstractCoordinateShape) shape);
759 else if (shape instanceof AbstractCompositeShape)
760 ((AbstractCompositeShape) shape).collectRenderTriangles(out);
765 * Hook: post-processes the freshly rebuilt render list before it becomes
766 * the rendering cache. The default implementation returns the list
767 * unchanged. Subclasses may replace the list — e.g.
768 * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}
769 * wraps its polygons into lightmapped triangles here.
771 * @param renderList the render list built from the shape registry
772 * @return the render list to cache and render
774 protected List<AbstractShape> postprocessRenderList(final List<AbstractShape> renderList) {
779 * Triangulates a convex solid polygon using fan triangulation.
781 * <p>Fan triangulation creates N-2 triangles from an N-vertex polygon by using
782 * vertex 0 as the anchor and connecting it to each adjacent pair of vertices.</p>
784 * <p>Properties (color, shading, backface culling, mouse interaction) are
785 * propagated to each resulting triangle to ensure consistent behavior.</p>
787 * @param polygon the polygon to triangulate (must have at least 4 vertices)
788 * @param result the list to add the resulting triangles to
790 private void triangulateSolidPolygon(final SolidPolygon polygon,
791 final List<AbstractShape> result) {
793 final Color color = polygon.getColor();
794 final boolean shadingEnabled = polygon.isShadingEnabled();
795 final boolean backfaceCulling = polygon.isBackfaceCullingEnabled();
796 final MouseInteractionController mouseController = polygon.mouseInteractionController;
798 final List<Vertex> vertices = polygon.vertices;
799 final Vertex v0 = vertices.get(0);
801 for (int i = 1; i < vertices.size() - 1; i++) {
802 final Vertex v1 = vertices.get(i);
803 final Vertex v2 = vertices.get(i + 1);
805 final SolidPolygon triangle = new SolidPolygon(
806 v0.coordinate, v1.coordinate, v2.coordinate, color);
808 triangle.setShadingEnabled(shadingEnabled);
809 triangle.setBackfaceCulling(backfaceCulling);
810 triangle.setMouseInteractionController(mouseController);
812 result.add(triangle);
817 public void transform(final TransformStack transformPipe,
818 final RenderAggregator aggregator, final RenderingContext context) {
820 // Add the current composite shape transform to the end of the transform
822 transformPipe.addTransform(transform);
824 // FRUSTUM CULLING: Check if this composite's bounds are visible
825 // Root composite skips this check (its bounds are always the full scene)
826 // Non-root composites check their aggregated bounds against the frustum
827 if (context.frustum != null && !isRootComposite) {
828 // Count this composite for culling statistics (before frustum test)
829 if (context.cullingStatistics != null) {
830 context.cullingStatistics.totalComposites.incrementAndGet();
833 final Box localBounds = getBoundingBox();
835 // Transform all 8 corners of the bounding box to view space
836 final double minX = localBounds.getMinX();
837 final double maxX = localBounds.getMaxX();
838 final double minY = localBounds.getMinY();
839 final double maxY = localBounds.getMaxY();
840 final double minZ = localBounds.getMinZ();
841 final double maxZ = localBounds.getMaxZ();
843 final double[] xs = {minX, maxX};
844 final double[] ys = {minY, maxY};
845 final double[] zs = {minZ, maxZ};
847 double viewMinX = Double.MAX_VALUE;
848 double viewMaxX = -Double.MAX_VALUE;
849 double viewMinY = Double.MAX_VALUE;
850 double viewMaxY = -Double.MAX_VALUE;
851 double viewMinZ = Double.MAX_VALUE;
852 double viewMaxZ = -Double.MAX_VALUE;
854 for (int i = 0; i < 8; i++) {
855 final double x = xs[(i & 1)];
856 final double y = ys[(i >> 1) & 1];
857 final double z = zs[(i >> 2) & 1];
859 final Point3D corner = transformPointToViewSpace(x, y, z, transformPipe);
861 viewMinX = Math.min(viewMinX, corner.x);
862 viewMaxX = Math.max(viewMaxX, corner.x);
863 viewMinY = Math.min(viewMinY, corner.y);
864 viewMaxY = Math.max(viewMaxY, corner.y);
865 viewMinZ = Math.min(viewMinZ, corner.z);
866 viewMaxZ = Math.max(viewMaxZ, corner.z);
869 final Box viewSpaceBounds = new Box(
870 new Point3D(viewMinX, viewMinY, viewMinZ),
871 new Point3D(viewMaxX, viewMaxY, viewMaxZ)
874 final Frustum frustum = context.frustum;
875 final boolean visible = frustum.intersectsAABB(viewSpaceBounds);
878 // Entire composite outside frustum - skip processing all children
879 if (context.cullingStatistics != null) {
880 context.cullingStatistics.culledComposites.incrementAndGet();
882 transformPipe.dropTransform();
887 viewSpaceTracker.analyze(transformPipe, context);
889 beforeTransformHook(transformPipe, context);
891 rebuildRenderListIfNeeded(context);
893 // transform rendered subshapes
894 if (shouldForkTransform(context)) {
895 transformChildrenParallel(transformPipe, aggregator, context);
897 transformChildrenSerial(transformPipe, aggregator, context);
900 transformPipe.dropTransform();
904 * Minimum number of children before the parallel fork is considered at
905 * all. A single child cannot be split; splitting happens in the child.
907 private static final int PARALLEL_TRANSFORM_MIN_SHAPES = 2;
910 * Minimum total subtree weight (leaf primitives below this composite)
911 * before forking its transform into parallel chunks. Below this, the
912 * serial walk is cheaper than the fork overhead. Deliberately above
913 * one sphere's generated triangle count (~960 at 16 segments):
914 * measured 2026-09-04, forking those pays task overhead per chunk for
915 * negligible serial work (35k chunk tasks on a 3000-sphere scene were
916 * SLOWER than serial).
918 private static final int PARALLEL_TRANSFORM_MIN_SUBTREE_WEIGHT = 2048;
921 * Minimum weight per parallel chunk task. Keeps chunk granularity
922 * coarse enough that task dispatch overhead stays negligible.
924 private static final int PARALLEL_TASK_MIN_WEIGHT = 512;
927 * Cached subtree weight from {@link #getTransformWeight}, valid for
928 * {@link #subtreeWeightCycle} only.
930 private int cachedSubtreeWeight;
933 * Transform cycle id the cached subtree weight was computed on.
934 * Keyed on the globally unique cycle id, not the per-context frame
935 * number, so alternating between rendering contexts cannot produce
938 private long subtreeWeightCycle = -1;
941 * Transform cycle id the cached subtree weight was last RECOMPUTED on.
942 * Separate from {@link #subtreeWeightCycle} (last read): the refresh
943 * gate measures the age of the computation, not of the last access.
945 private long subtreeWeightComputeCycle = -1;
948 * {@link #renderListVersion} at the last weight recomputation.
950 private int weightListVersion = -1;
953 * Bumped whenever ANY composite rebuilds its render list. Lets the
954 * weight shortcut react to structural changes anywhere in the tree
955 * within one cycle, at O(1) per node per cycle — scanning direct
956 * children's versions instead costs O(leaves) per frame because leaf
957 * lists dominate (measured 2026-09-04: +3-4 ms/frame on a 400-sphere
960 private static final AtomicInteger globalRenderListVersion = new AtomicInteger();
963 * {@link #globalRenderListVersion} value seen at the last weight
966 private int weightGlobalVersion = -1;
969 * Bumped every time {@link #cachedRenderList} is rebuilt. Gates weight
970 * recomputation: while the list is unchanged, the cached weight is
971 * reused without re-walking the subtree.
973 private int renderListVersion;
976 * Full subtree weight re-walks are O(total leaves below this node),
977 * which costs real milliseconds on big meshes. With an unchanged render
978 * list the cached weight is refreshed at most every this many cycles;
979 * structural changes (rebuilds) recompute immediately.
981 private static final long WEIGHT_REFRESH_CYCLES = 16;
984 * Total transform weight of this composite: the sum of its children's
985 * weights, i.e. roughly the number of leaf primitives below it.
986 * Computed lazily; recomputed only when this node's render list was
987 * rebuilt or the cache is older than {@link #WEIGHT_REFRESH_CYCLES}
988 * cycles (children's internal rebuilds are picked up by the periodic
989 * refresh). Used solely for parallel fork load balancing, never for
990 * correctness, so brief staleness is harmless.
992 * <p>Thread safety: a composite's fork decision runs on exactly one
993 * thread per cycle. Chunk-thread reads are cycle-stamped cache hits
994 * published through the executor's happens-before edge.</p>
996 * @param renderingContext the rendering context (cycle identity)
997 * @return subtree transform weight, at least 1
1000 public int getTransformWeight(final RenderingContext renderingContext) {
1001 final long cycle = renderingContext.transformCycleId;
1002 if (subtreeWeightCycle == cycle) {
1003 return cachedSubtreeWeight;
1005 final int globalVersion = globalRenderListVersion.get();
1006 if (weightListVersion == renderListVersion
1007 && weightGlobalVersion == globalVersion
1008 && cycle - subtreeWeightComputeCycle < WEIGHT_REFRESH_CYCLES) {
1009 // Nothing rebuilt anywhere and computation fresh: keep the
1010 // value, just re-stamp the read. O(1) per node per cycle.
1011 subtreeWeightCycle = cycle;
1012 return cachedSubtreeWeight;
1015 for (final AbstractShape child : cachedRenderList) {
1016 weight += child.getTransformWeight(renderingContext);
1018 cachedSubtreeWeight = Math.max(1, weight);
1019 subtreeWeightCycle = cycle;
1020 subtreeWeightComputeCycle = cycle;
1021 weightListVersion = renderListVersion;
1022 weightGlobalVersion = globalVersion;
1023 return cachedSubtreeWeight;
1027 * Decides whether this composite forks its children's transform into
1028 * parallel chunks: enough children to split, and enough TOTAL weight
1029 * below it to amortize the fork overhead. Weight (not local child
1030 * count) is what matters: a deep narrow tree with two heavy children
1031 * forks just like a flat mesh with thousands of leaves.
1033 * @param context the rendering context (provides the coordinator)
1034 * @return true when the parallel fork should be taken
1036 private boolean shouldForkTransform(final RenderingContext context) {
1037 if (context.transformCoordinator == null) {
1040 if (cachedRenderList.size() < PARALLEL_TRANSFORM_MIN_SHAPES) {
1043 return getTransformWeight(context) >= PARALLEL_TRANSFORM_MIN_SUBTREE_WEIGHT;
1047 * Target number of task chunks per available processor core.
1048 * More tasks than cores gives the pool load balancing across
1049 * shapes with uneven transform cost.
1051 private static final int PARALLEL_TASKS_PER_CORE = 4;
1054 * Transforms all children serially on the calling thread.
1056 * @param transformPipe the transform stack (includes this composite's transform)
1057 * @param aggregator the aggregator to queue visible shapes into
1058 * @param context the rendering context
1060 private void transformChildrenSerial(final TransformStack transformPipe,
1061 final RenderAggregator aggregator,
1062 final RenderingContext context) {
1063 for (final AbstractShape shape : cachedRenderList) {
1064 shape.transform(transformPipe, aggregator, context);
1069 * Forks the children's transform into parallel chunk tasks on the
1070 * frame's {@link ParallelTransformCoordinator} and returns immediately
1071 * WITHOUT waiting for them.
1073 * <p>Works at any nesting level: a heavy composite reached inside a
1074 * chunk task forks its own children into the same coordinator. This is
1075 * deadlock-safe because chunk tasks never block on other tasks; only
1076 * the orchestrating render thread waits (in the coordinator's drain).</p>
1078 * <p>Stack snapshotting: this composite's transform is dropped from
1079 * {@code transformPipe} right after this method returns, long before
1080 * the chunk tasks run, so the pipe is copied HERE on the forking
1081 * thread. Each task then copies the snapshot for its own working stack.
1082 * The snapshot is never mutated after publication, so concurrent
1083 * copying by chunk tasks is safe.</p>
1085 * <p>Thread-safety notes: sibling composites are exclusively owned by
1086 * one chunk, so their per-instance caches (render list, bounding
1087 * boxes, own transform's cached matrix) never race. The vertex
1088 * frameNumber cache is a benign race: every thread writes the same
1091 * @param transformPipe the transform stack (includes this composite's transform)
1092 * @param aggregator the caller's aggregator, used ONLY when the fork
1093 * bails out (too few chunks, or the frame task
1094 * budget is exhausted) and the children fall back
1095 * to a serial inline transform. The parallel fork
1096 * itself queues into per-task aggregators merged
1097 * by the coordinator's drain.
1098 * @param context the rendering context (provides the coordinator)
1100 private void transformChildrenParallel(final TransformStack transformPipe,
1101 final RenderAggregator aggregator,
1102 final RenderingContext context) {
1103 // Snapshot the render list reference. With the pipelined render
1104 // loop, the NEXT pass's tree walk can already be running while
1105 // this pass's chunk tasks are still queued (the drain happens in
1106 // the async continuation, not before the next walk). That walk
1107 // may rebuild this composite's render list, REASSIGNING
1108 // cachedRenderList to a new list of a different size. The chunk
1109 // ranges below are computed against this list instance, so the
1110 // chunk tasks must index this same instance — re-reading the
1111 // field inside the lambda raced with the rebuild and threw
1112 // IndexOutOfBoundsException. (The old list stays alive and valid
1113 // for this pass; each pass transforms into its own vertex slot.)
1114 final List<AbstractShape> renderList = cachedRenderList;
1115 final int size = renderList.size();
1116 final int totalWeight = getTransformWeight(context);
1117 final int processors = Runtime.getRuntime().availableProcessors();
1118 final int targetTasks = processors * PARALLEL_TASKS_PER_CORE;
1119 final int taskWeight = Math.max(PARALLEL_TASK_MIN_WEIGHT,
1120 (totalWeight + targetTasks - 1) / targetTasks);
1122 // Pass 1: count chunks, cutting by ACCUMULATED WEIGHT so that a
1123 // node with few but heavy children (e.g. two 25k-triangle halves
1124 // of a fractal) still splits into multiple tasks
1126 int accumulated = 0;
1127 for (int i = 0; i < size; i++) {
1128 accumulated += renderList.get(i).getTransformWeight(context);
1129 if (accumulated >= taskWeight) {
1134 if (accumulated > 0) {
1138 if (taskCount < 2) {
1139 transformChildrenSerial(transformPipe, aggregator, context);
1143 final ParallelTransformCoordinator coordinator = context.transformCoordinator;
1144 if (!coordinator.tryReserveTasks(taskCount)) {
1145 // Frame-wide task budget exhausted: transform inline
1146 transformChildrenSerial(transformPipe, aggregator, context);
1150 final TransformStack snapshot = new TransformStack(transformPipe);
1152 // Pass 2: submit chunks (weights are frame-cached, cheap re-walk)
1155 for (int i = 0; i < size; i++) {
1156 accumulated += renderList.get(i).getTransformWeight(context);
1157 if (accumulated >= taskWeight || i == size - 1) {
1158 final int chunkFrom = from;
1159 final int chunkTo = i + 1;
1160 coordinator.submit(() -> {
1161 // Pooled scratch: the stack (9.6 KB of arrays) and
1162 // the chunk aggregator (queue keeps its capacity
1163 // across frames) come from the coordinator's static
1164 // pools — previously each chunk allocated both on
1166 final TransformStack taskStack =
1167 coordinator.borrowStack(snapshot);
1169 final RenderAggregator taskAggregator =
1170 coordinator.borrowAggregator();
1171 for (int c = chunkFrom; c < chunkTo; c++) {
1172 renderList.get(c).transform(taskStack, taskAggregator, context);
1174 return taskAggregator;
1176 coordinator.returnStack(taskStack);
1186 * Transforms a point to view space using the current transform stack.
1187 * Helper method for frustum culling that transforms bounding box corners.
1189 * @param x the X coordinate in local space
1190 * @param y the Y coordinate in local space
1191 * @param z the Z coordinate in local space
1192 * @param transformPipe the current transform stack
1193 * @return the transformed point in view space
1195 private Point3D transformPointToViewSpace(final double x, final double y, final double z,
1196 final TransformStack transformPipe) {
1197 final Point3D input = new Point3D(x, y, z);
1198 final Point3D result = new Point3D();
1199 transformPipe.transform(input, result);