From: Svjatoslav Agejenko Date: Sat, 19 Sep 2026 18:50:12 +0000 (+0300) Subject: initial commit X-Git-Tag: aukio-3d-1.0.0~12 X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;h=ad3d82e57ab465de7467f9bbe42e457c2d933958;p=aukio-3d.git initial commit --- ad3d82e57ab465de7467f9bbe42e457c2d933958 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31378ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.idea/ +/target/ +/.classpath +/.project +/.settings/ +/doc/graphs/ +/doc/apidocs/ +/*.iml +*.html diff --git a/AGENTS.org b/AGENTS.org new file mode 100644 index 0000000..7dc28c0 --- /dev/null +++ b/AGENTS.org @@ -0,0 +1,575 @@ +:PROPERTIES: +:ID: d69fec29-3842-4e10-aece-89829c522c79 +:END: +#+TITLE: Aukio 3D Engine - Quick Reference +#+LANGUAGE: en +#+OPTIONS: H:20 num:20 author:nil + +Software-based 3D rendering engine (no OpenGL/DirectX). Pure Java +rasterizer with texture support, lighting, CSG operations, and camera +navigation. + +* Quick Lookup: "I Want To..." +:PROPERTIES: +:ID: 5136cb8e-4ade-4a28-91f7-3ec0ee98721c +:END: + +| Task | Class (path) | Key Constructor/Method | +|-------------------------------+-----------------------------------------------------+-----------------------------------------------------------| +| *Create a window* | ~ViewFrame~ (~gui/ViewFrame.java~) | ~new ViewFrame()~ → ~.getViewPanel()~ | +| *Add shapes to scene* | ~ShapeCollection~ (~raster/ShapeCollection.java~) | ~viewPanel.getRootShapeCollection().addShape(shape)~ | +| *Position camera* | ~Camera~ (~gui/Camera.java~) | ~camera.getTransform().setTranslation(Point3D)~ | +| *Create a wireframe cube* | ~WireframeCube~ (~shapes/composite/wireframe/~) | ~new WireframeCube(center, halfSize, appearance)~ | +| *Create a solid cube* | ~SolidPolygonCube~ (~shapes/composite/solid/~) | ~new SolidPolygonCube(center, halfSize, color)~ | +| *Create a line* | ~Line~ (~shapes/basic/line/~) | ~new Line(p1, p2, color, width)~ | +| *Create a polygon* | ~SolidPolygon~ (~shapes/basic/solidpolygon/~) | ~SolidPolygon.triangle(...)~ or ~.quad(...)~ | +| *Create a sphere (wireframe)* | ~WireframeSphere~ (~shapes/composite/wireframe/~) | ~new WireframeSphere(center, radius, appearance)~ | +| *Create a sphere (solid)* | ~SolidPolygonSphere~ (~shapes/composite/solid/~) | ~new SolidPolygonSphere(center, radius, segments, color)~ | +| *Create text in 3D* | ~TextCanvas~ (~shapes/composite/textcanvas/~) | ~new TextCanvas(transform, text, fgColor, bgColor)~ | +| *Add a light source* | ~LightSource~ (~raster/lighting/~) | ~lighting.addLight(new LightSource(pos, color))~ | +| *Enable shading* | ~AbstractCompositeShape~ (~shapes/composite/base/~) | ~shape.setShadingEnabled(true)~ | +| *CSG: subtract* | ~AbstractCompositeShape~ (~shapes/composite/base/~) | ~shape.subtract(otherShape)~ | +| *CSG: union* | ~AbstractCompositeShape~ (~shapes/composite/base/~) | ~shape.union(otherShape)~ | +| *CSG: intersect* | ~AbstractCompositeShape~ (~shapes/composite/base/~) | ~shape.intersect(otherShape)~ | +| *Animate per-frame* | ~FrameListener~ (~gui/FrameListener.java~) | ~viewPanel.addFrameListener((panel, deltaMs) -> {...})~ | +| *Handle mouse clicks* | ~MouseInteractionController~ (~gui/humaninput/~) | ~shape.setMouseInteractionController(controller)~ | +| *Position/rotate shape* | ~AbstractCompositeShape~ (~shapes/composite/base/~) | ~new AbstractCompositeShape(location)~ | +| *Hide/show shape groups* | ~ShapeCollection~ (~raster/ShapeCollection.java~) | ~.hideGroup("debug")~ / ~.showGroup("debug")~ | +| *Create a billboard* | ~Billboard~ (~shapes/basic/~) | ~new Billboard(position, scale, texture)~ | +| *Create a glowing point* | ~GlowingPoint~ (~shapes/basic/~) | ~new GlowingPoint(position, scale, color)~ | +| *Render without a window* | ~Snapshot~ (~headless/Snapshot.java~) | ~Snapshot.render(scene, lighting, pose, w, h)~ | +| *Assert pixels painted* | ~PixelAssertions~ (~headless/PixelAssertions.java~) | ~.unpaintedFraction(image, bg, x0, y0, x1, y1)~ | +| *Compare against golden PNG* | ~GoldenImage~ (~headless/GoldenImage.java~) | ~.compare(actual, goldenFile, tolerance, maxFraction)~ | +| *Dump scene state* | ~SceneDump~ (~headless/SceneDump.java~) | ~SceneDump.dump(scene, lighting, camera, gi)~ | + +* Code Examples +:PROPERTIES: +:ID: fdd7c315-c513-4925-8ca5-acb1f87a7380 +:END: + +** Basic Scene Setup + +#+begin_src java +import eu.svjatoslav.aukio.e3d.gui.ViewFrame; +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; +import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; + +// Create window with 3D view +ViewFrame frame = new ViewFrame(); +ViewPanel viewPanel = frame.getViewPanel(); +ShapeCollection scene = viewPanel.getRootShapeCollection(); + +// Position camera (behind origin, looking forward) +viewPanel.getCamera().getTransform().setTranslation(new Point3D(0, 0, -200)); + +// Add shapes here... +// scene.addShape(...); +#+end_src + +** Creating Shapes + +#+begin_src java +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.*; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.*; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.*; + +// Wireframe shapes (use LineAppearance for color/width) +LineAppearance appearance = new LineAppearance(2.0, Color.CYAN); +scene.addShape(new WireframeCube(new Point3D(0, 0, 200), 50, appearance)); +scene.addShape(new WireframeSphere(new Point3D(100, 0, 300), 40, appearance)); +scene.addShape(new WireframeBox(p1, p2, appearance)); + +// Solid shapes (use Color directly) +scene.addShape(new SolidPolygonCube(new Point3D(0, 0, 200), 50, Color.GREEN)); +scene.addShape(new SolidPolygonSphere(new Point3D(100, 0, 300), 40, 16, Color.RED)); + +// Simple line +scene.addShape(new Line( + new Point3D(-50, 0, 100), + new Point3D(50, 0, 100), + Color.YELLOW, 3.0 +)); + +// Polygon (triangle or quad) +scene.addShape(SolidPolygon.triangle( + new Point3D(0, 0, 0), + new Point3D(50, 0, 0), + new Point3D(25, 50, 0), + Color.BLUE +)); +scene.addShape(SolidPolygon.quad( + new Point3D(-50, -50, 0), + new Point3D(50, -50, 0), + new Point3D(50, 50, 0), + new Point3D(-50, 50, 0), + Color.WHITE +)); +#+end_src + +** Custom Composite Shape + +#+begin_src java +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +AbstractCompositeShape myShape = new AbstractCompositeShape(new Point3D(0, 0, 200)); +myShape.addShape(new Line(p1, p2, Color.RED, 2.0)); +myShape.addShape(new SolidPolygonCube(Point3D.origin(), 10, Color.BLUE)); +scene.addShape(myShape); +#+end_src + +** Lighting and Shading + +#+begin_src java +import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.*; + +LightingManager lighting = viewPanel.getLightingManager(); +lighting.addLight(new LightSource(new Point3D(100, -100, 200), Color.YELLOW)); +lighting.setAmbientLight(new Color(20, 20, 20)); + +// Enable shading on solid shapes +SolidPolygonSphere sphere = new SolidPolygonSphere(Point3D.origin(), 50, 16, Color.RED); +sphere.setShadingEnabled(true); +scene.addShape(sphere); +#+end_src + +** CSG Operations (Boolean Operations) + +#+begin_src java +// Create two shapes +SolidPolygonCube box = new SolidPolygonCube(new Point3D(0, 0, 200), 50, Color.GREEN); +SolidPolygonSphere sphere = new SolidPolygonSphere(new Point3D(0, 0, 200), 35, 16, Color.RED); + +// Subtract sphere from box (creates a box with spherical hole) +box.subtract(sphere); +scene.addShape(box); + +// Union: combine shapes +// box.union(sphere); + +// Intersect: keep only overlapping parts +// box.intersect(sphere); +#+end_src + +** Text in 3D + +#+begin_src java +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas; +import eu.svjatoslav.aukio.e3d.gui.TextPointer; +import eu.svjatoslav.aukio.e3d.math.Transform; + +// Create text canvas +Transform location = new Transform(new Point3D(0, 0, 500)); +TextCanvas canvas = new TextCanvas(location, "Hello World!", Color.WHITE, Color.BLACK); +scene.addShape(canvas); + +// Or create blank canvas and write to it +TextCanvas blank = new TextCanvas(location, new TextPointer(10, 40), Color.GREEN, Color.BLACK); +blank.locate(0, 0); // row 0, column 0 +blank.print("Line 1"); +blank.locate(1, 0); +blank.print("Line 2"); +blank.setForegroundColor(Color.YELLOW); +blank.putChar('X'); +#+end_src + +** Animation with FrameListener + +#+begin_src java +viewPanel.addFrameListener((panel, deltaMs) -> { + double rotationIncrement = deltaMs * 0.001; // radians per ms + + // Update shape transform + currentAngle += rotationIncrement; + myShape.setTransform(new Transform( + myShape.getLocation(), + currentAngle, 0 // yaw, pitch + )); + + return true; // return true to request repaint +}); +#+end_src + +** Camera Control + +#+begin_src java +import eu.svjatoslav.aukio.e3d.math.Quaternion; + +Camera camera = viewPanel.getCamera(); + +// Set position +camera.getTransform().setTranslation(new Point3D(100, -50, -300)); + +// Set orientation (quaternion from yaw/pitch angles) +camera.getTransform().getRotation().set(Quaternion.fromAngles(0.5, -0.3)); + +// Look at a specific point (convenience method) +// camera.lookAt(new Point3D(0, 0, 200)); +#+end_src + +** Mouse Interaction on Shapes + +#+begin_src java +import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController; +import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent; + +SolidPolygonCube clickableCube = new SolidPolygonCube(Point3D.origin(), 50, Color.BLUE); +clickableCube.setMouseInteractionController(new MouseInteractionController() { + @Override + public void mouseClicked(final MouseEvent event) { + System.out.println("Cube clicked at: " + event.coordinate); + } + + @Override + public void mouseEntered(final MouseEvent event) { + clickableCube.setColor(Color.RED); + } + + @Override + public void mouseExited(final MouseEvent event) { + clickableCube.setColor(Color.BLUE); + } +}); +scene.addShape(clickableCube); +#+end_src + +* Class Catalog +:PROPERTIES: +:ID: 5522594a-969f-4cdb-b80e-d3d6bc732647 +:END: + +** Geometry (~geometry/~) + +| Class | File | Purpose | Key Methods | +|-----------+----------------+---------------------------------------------------------+-------------------------------------------------------------------------------------------------------------------| +| ~Point3D~ | ~Point3D.java~ | Mutable 3D point/vector. *Public fields:* ~x~, ~y~, ~z~ | ~.add()~, ~.subtract()~, ~.multiply()~, ~.rotate()~, ~.getDistanceTo()~, ~.clone()~, ~.withAdded()~ (returns new) | +| ~Point2D~ | ~Point2D.java~ | 2D screen coordinate | ~.add()~, ~.subtract()~, ~.to3D()~ | +| ~Box~ | ~Box.java~ | Axis-aligned bounding box | ~.getCenter()~, ~.enlarge()~, ~.intersectsAABB()~ | +| ~Frustum~ | ~Frustum.java~ | View frustum (6 planes) | ~.update()~, ~.intersectsAABB()~ | +| ~BspTree~ | ~BspTree.java~ | BSP tree for CSG | ~.addPolygons()~, ~.clipPolygons()~, ~.invert()~, ~.allPolygons()~ | +| ~Plane~ | ~Plane.java~ | Infinite plane (Hesse normal) | ~.fromPoints()~, ~.splitPolygon()~ | + +** Math (~math/~) + +| Class | File | Purpose | Key Methods | +|------------------+-----------------------+-------------------------------+---------------------------------------------------------------------------------| +| ~Transform~ | ~Transform.java~ | Translation + rotation | ~.setTranslation()~, ~.transform(point)~, ~.withTransformed()~ | +| ~TransformStack~ | ~TransformStack.java~ | Stack of transforms | ~.addTransform()~, ~.transform()~, ~.dropTransform()~ | +| ~Quaternion~ | ~Quaternion.java~ | 3D rotation (unit quaternion) | ~.fromAngles(yaw, pitch)~, ~.multiply()~, ~.invert()~, ~.toMatrix3x3()~ | +| ~Vertex~ | ~Vertex.java~ | Wraps Point3D + transform | ~.coordinate~, ~.transformedCoordinate~, ~.calculateLocationRelativeToViewer()~ | + +** Renderer Core (~renderer/raster/~) + +| Class | File | Purpose | Key Methods | +|--------------------+-------------------------+----------------------------------+-----------------------------------------------------------------------------------------------------| +| ~Color~ | ~Color.java~ | RGBA color (NOT java.awt.Color!) | ~.set(r,g,b,a)~, ~.toAwtColor()~. Constants: ~RED~, ~GREEN~, ~BLUE~, ~BLACK~, ~WHITE~, ~CYAN~, etc. | +| ~ShapeCollection~ | ~ShapeCollection.java~ | Root scene container | ~.addShape()~, ~.hideGroup()~, ~.showGroup()~, ~.removeGroup()~ | +| ~RenderAggregator~ | ~RenderAggregator.java~ | Collects, sorts, paints shapes | ~.queueShapeForRendering()~, ~.sort()~, ~.paint()~ | + +** Shapes - Base (~renderer/raster/shapes/~) + +| Class | File | Purpose | +|---------------------------+---------------------------------------+-----------------------------------------------------------------| +| ~AbstractShape~ | ~shapes/AbstractShape.java~ | Base class for all shapes. Bounding box caching. | +| ~AbstractCoordinateShape~ | ~shapes/AbstractCoordinateShape.java~ | Base for shapes with vertices. Has ~List~, ~onScreenZ~. | + +** Shapes - Basic (~shapes/basic/~) + +| Class | File | Purpose | Constructor | +|--------------------+-----------------------------------------------+------------------------------+---------------------------------------------------------| +| ~Line~ | ~basic/line/Line.java~ | 3D line segment | ~new Line(p1, p2, color, width)~ | +| ~LineAppearance~ | ~basic/line/LineAppearance.java~ | Factory for consistent lines | ~new LineAppearance(width, color)~ → ~.getLine(p1, p2)~ | +| ~SolidPolygon~ | ~basic/solidpolygon/SolidPolygon.java~ | Solid convex N-gon | ~SolidPolygon.triangle(...)~ or ~.quad(...)~ | +| ~TexturedTriangle~ | ~basic/texturedpolygon/TexturedTriangle.java~ | Textured triangle with UV | ~new TexturedTriangle(v1,v2,v3,texture)~ | +| ~Billboard~ | ~basic/Billboard.java~ | Texture facing camera | ~new Billboard(position, scale, texture)~ | +| ~GlowingPoint~ | ~basic/GlowingPoint.java~ | Glowing circular point | ~new GlowingPoint(position, scale, color)~ | + +** Shapes - Composite Wireframe (~shapes/composite/wireframe/~) + +| Class | Constructor | +|---------------------+-------------------------------------------------------------| +| ~WireframeCube~ | ~new WireframeCube(center, halfSize, appearance)~ | +| ~WireframeBox~ | ~new WireframeBox(corner1, corner2, appearance)~ | +| ~WireframeSphere~ | ~new WireframeSphere(center, radius, appearance)~ | +| ~WireframeCylinder~ | ~new WireframeCylinder(center, radius, height, appearance)~ | +| ~WireframeCone~ | ~new WireframeCone(center, radius, height, appearance)~ | +| ~WireframeArrow~ | ~new WireframeArrow(start, end, appearance)~ | +| ~Grid2D~ | 2D grid plane | +| ~Grid3D~ | 3D grid in space | + +** Shapes - Composite Solid (~shapes/composite/solid/~) + +| Class | Constructor | +|------------------------------+-----------------------------------------------------------| +| ~SolidPolygonCube~ | ~new SolidPolygonCube(center, halfSize, color)~ | +| ~SolidPolygonRectangularBox~ | ~new SolidPolygonRectangularBox(corner1, corner2, color)~ | +| ~SolidPolygonSphere~ | ~new SolidPolygonSphere(center, radius, segments, color)~ | +| ~SolidPolygonCylinder~ | Solid cylinder | +| ~SolidPolygonCone~ | Solid cone | +| ~SolidPolygonArrow~ | Solid arrow | + +** Shapes - Composite Base (~shapes/composite/base/~) + +| Class | File | Purpose | Key Methods | +|--------------------------+------------------------------------+-----------------------+-------------------------------------------------------------------------------------------------| +| ~AbstractCompositeShape~ | ~base/AbstractCompositeShape.java~ | Group shapes, CSG ops | ~.addShape()~, ~.subtract()~, ~.union()~, ~.intersect()~, ~.setShadingEnabled()~, ~.setColor()~ | + +** Text (~shapes/composite/textcanvas/~) + +| Class | File | Purpose | Key Methods | +|--------------+------------------------------+-----------------+-----------------------------------------------------------------------------------| +| ~TextCanvas~ | ~textcanvas/TextCanvas.java~ | Text grid in 3D | ~.print()~, ~.locate(row,col)~, ~.clear()~, ~.setForegroundColor()~, ~.putChar()~ | + +** Texture (~renderer/raster/texture/~) + +| Class | File | Purpose | +|--------------------+---------------------------------+----------------------------------------------| +| ~Texture~ | ~texture/Texture.java~ | 2D texture with mipmaps | +| ~TextureBitmap~ | ~texture/TextureBitmap.java~ | Raw pixel array for one mipmap level | +| ~TextureGenerator~ | ~texture/TextureGenerator.java~ | Factory for common textures (glows, borders) | + +** Lighting (~renderer/raster/lighting/~) + +| Class | File | Purpose | Key Methods | +|-------------------+---------------------------------+----------------+-----------------------------------------------------------| +| ~LightingManager~ | ~lighting/LightingManager.java~ | Manages lights | ~.addLight()~, ~.setAmbientLight()~, ~.computeLighting()~ | +| ~LightSource~ | ~lighting/LightSource.java~ | Point light | ~new LightSource(position, color)~ → ~.setIntensity()~ | + +** GUI (~gui/~) + +| Class | File | Purpose | Key Methods | +|-----------------+--------------------------+------------------------------+--------------------------------------------------------------------------------------------------------| +| ~ViewPanel~ | ~gui/ViewPanel.java~ | AWT Canvas, render loop | ~.getRootShapeCollection()~, ~.getCamera()~, ~.getLightingManager()~, ~.addFrameListener()~, ~.stop()~ | +| ~ViewFrame~ | ~gui/ViewFrame.java~ | JFrame wrapper | ~new ViewFrame()~ → ~.getViewPanel()~ | +| ~Camera~ | ~gui/Camera.java~ | Viewer position/orientation | ~.getTransform()~, ~.setTransform()~ | +| ~FrameListener~ | ~gui/FrameListener.java~ | Per-frame callback interface | ~.onFrame(panel, deltaMs)~ → return true to repaint | + +** Input (~gui/humaninput/~) + +| Class | File | Purpose | +|------------------------------+----------------------------------------------+--------------------------------| +| ~InputManager~ | ~humaninput/InputManager.java~ | Mouse/keyboard tracking | +| ~MouseInteractionController~ | ~humaninput/MouseInteractionController.java~ | Interface for clickable shapes | +| ~KeyboardFocusStack~ | ~humaninput/KeyboardFocusStack.java~ | Focus management for widgets | + +** Octree Renderer (~renderer/octree/~) + +Alternative rendering path for voxel volumes with ray tracing. + +| Class | File | Purpose | +|----------------+-----------------------------------+-----------------------------| +| ~OctreeVolume~ | ~octree/OctreeVolume.java~ | Sparse voxel octree storage | +| ~RayTracer~ | ~octree/raytracer/RayTracer.java~ | Ray tracing renderer | + +* Architecture & Key Concepts +:PROPERTIES: +:ID: 4be2246b-fcb6-4a1f-bcf8-7d657a67d957 +:END: + +** Coordinate System (CRITICAL) + +Aukio 3D uses *left-handed coordinates* matching 2D screen space: + +| Axis | Positive = | Example | +|------+-------------+----------------------------------| +| X | RIGHT | Larger X = further right | +| Y | DOWN | Smaller Y = higher (up visually) | +| Z | INTO screen | Negative Z = closer to camera | + +*To place A ABOVE B:* give A a *smaller Y* (~y - offset~) +*To place A BELOW B:* give A a *larger Y* (~y + offset~) + +This is opposite to Y-up engines (OpenGL, Unity, Blender). + +** Shape Hierarchy + +#+begin_example +AbstractShape (base) + ├── AbstractCoordinateShape (has vertices) + │ ├── Line + │ ├── SolidPolygon + │ ├── TexturedTriangle + │ ├── Billboard + │ └── GlowingPoint + └── AbstractCompositeShape (groups shapes) + ├── Wireframe shapes (WireframeCube, WireframeSphere, ...) + ├── Solid shapes (SolidPolygonCube, SolidPolygonSphere, ...) + └── TextCanvas +#+end_example + +** Render Pipeline + +#+begin_example +ViewPanel.renderFrame() + 1. ShapeCollection.transformShapes() — apply camera transform + 2. ShapeCollection.sortShapes() — sort by Z (back-to-front) + 3. ShapeCollection.paintShapes() — painter's algorithm + 4. BufferStrategy.show() — page flip to display +#+end_example + +- Shapes implement ~transform()~ to project from world to screen space +- Shapes implement ~paint()~ to rasterize to pixel buffer +- ~onScreenZ~ determines render order (set during transform phase) + +** Backface Culling + +Uses signed area in screen space: +- ~signedArea < 0~ → front-facing (CCW winding) +- ~signedArea > 0~ → back-facing (CW winding) + +Vertex order for front face: *top → lower-left → lower-right* (as seen from camera) + +* Build & Test +:PROPERTIES: +:ID: 7909eb6a-c9a0-45f6-b1a5-a347e469c6cf +:END: + +#+begin_src bash +# Build +mvn clean install + +# Run all tests +mvn test + +# Run single test class +mvn test -Dtest=TextLineTest + +# Run specific test method +mvn test -Dtest=TextLineTest#testAddIdent + +# Golden-image regression tests (aukio-3d-demos repo) +cd ../aukio-3d-demos && mvn clean package +mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt +java -cp "target/classes:$(cat cp.txt)" \ + eu.svjatoslav.aukio.e3d.examples.goldens.HouseGoldens # verify +java -cp "target/classes:$(cat cp.txt)" \ + eu.svjatoslav.aukio.e3d.examples.goldens.HouseGoldens --update # regenerate goldens + +# Regenerate all HTML documentation (org -> HTML, darksun theme) +doc/export-docs.sh # export +doc/export-docs.sh --check # export + headless-Chrome screenshots to /tmp +#+end_src + +Test files: ~src/test/java/~ (JUnit 4) + +* Headless Testing Toolkit (~headless/~) +:PROPERTIES: +:ID: b1f4a2c8-headless-toolkit +:END: + +Windowless rendering and verification — no Swing frame, no X display, +no render thread. Drives the same transform/sort/paint pipeline the +on-screen path uses. Built for tests, doc tooling and AI agents. + +** Rendering a snapshot + +#+begin_src java +import eu.svjatoslav.aukio.e3d.headless.Snapshot; + +// Pose string = "x, y, z, yaw, pitch, roll" — the exact format demos +// print and bug reports quote. Snapshot.cameraFromPose / poseString +// convert both ways. +BufferedImage image = Snapshot.render(scene, lighting, + "290.31, -35.59, -2.10, -0.58, -0.15, 0.0", 640, 480); +Snapshot.save(image, "/tmp/snap.png"); +#+end_src + +~Snapshot.render(scene, lighting, camera, w, h)~ is the Camera-based +overload; ~Snapshot.renderInto(scene, camera, ctx, backgroundArgb)~ +paints into an existing ~RenderingContext~ with a chosen background +(sentinel color = "unpainted" for hole detection). + +** Pixel assertions + +#+begin_src java +import eu.svjatoslav.aukio.e3d.headless.PixelAssertions; + +double holes = PixelAssertions.unpaintedFraction(image, 0, + 0.15, 0.45, 0.85, 1.0); // lower-center band, relative coords +long red = PixelAssertions.countColor(image, 0xFF0000); +String grid = PixelAssertions.dumpPixelGrid(image, 320, 240, 3, 8); +#+end_src + +** Golden-image comparison + +#+begin_src java +import eu.svjatoslav.aukio.e3d.headless.GoldenImage; + +GoldenImage.Result r = GoldenImage.compare(actual, + new File("goldens/house.png"), 4, 0.005); // channel tol, max diff fraction +GoldenImage.saveDiff(actual, goldenFile, "/tmp/diff.png"); // red = differs +#+end_src + +CLI: ~java eu.svjatoslav.aukio.e3d.headless.GoldenImage a.png b.png [tol] [maxFrac]~ +(exit 0 = match, 1 = differ). + +The House demo has ready-made goldens in the aukio-3d-demos repo: +~eu.svjatoslav.aukio.e3d.examples.goldens.HouseGoldens~ checks two poses +(default view + the near-plane straddle bug pose) against +~aukio-3d-demos/goldens/*.png~ and asserts the floor has no holes. +Run ~--update~ to regenerate after an intentional visual change. + +** Scene dump + +#+begin_src java +import eu.svjatoslav.aukio.e3d.headless.SceneDump; + +System.out.println(SceneDump.dump(scene, lighting, camera, gi)); +// shapes: 5 top-level, 546 queued for rendering +// lights: 4 (ambient #181818) + per-light pos/color/intensity +// camera: 290.31, -35.59, -2.10, -0.58, -0.15, -0.00 +// GI: running, 152034 work items, converged +#+end_src + +** Engine API added for headless use + +- ~ShapeCollection.transformShapes(Camera, RenderingContext)~ — camera-based + overload; the ~ViewPanel~ variant delegates to it +- ~ShapeCollection.getRootComposite()~ — direct root access (GI snapshots) +- ~RenderingContext.getImage()~ — the backing BufferedImage +- ~GlobalIllumination.isRunning() / isConverged() / getWorkItemCount()~ + +** House demo scene without a window (aukio-3d-demos) + +~HouseDemo.buildHouse(house)~, ~HouseDemo.addFurniture(house)~ and +~HouseDemo.addLights(lighting, scene)~ are public: tests and tools can +rebuild the exact demo scene headlessly. + +* Tips for AI Agents +:PROPERTIES: +:ID: 597b2b14-1ee1-440b-bd40-ab7f3ed405de +:END: + +1. *Always use project Color:* ~eu.svjatoslav.aukio.e3d.renderer.raster.Color~ (NOT ~java.awt.Color~) +2. *Point3D is mutable:* Clone before storing references: ~point.clone()~ +3. *Y is down:* Remember coordinate system when positioning elements +4. *SolidPolygon works for quads:* Use ~SolidPolygon.quad(p1,p2,p3,p4,color)~ - automatically triangulated +5. *CSG on AbstractCompositeShape:* Only composite shapes support ~subtract()~, ~union()~, ~intersect()~ +6. *Animations via FrameListener:* Return ~true~ from ~onFrame()~ to trigger repaint +7. *Shading needs lighting:* ~setShadingEnabled(true)~ + add ~LightSource~ to ~LightingManager~ +8. *Wireframe shapes need LineAppearance:* ~new LineAppearance(width, Color)~ for consistent line styling +9. *Group visibility:* Use ~.addShape(shape, "groupName")~ then ~.hideGroup()~ / ~.showGroup()~ + +* Documentation (Org Mode) +:PROPERTIES: +:ID: 1d942e3b-6071-440f-bd9b-876c8f7c53de +:END: + +| Path | Topic | +|----------------------------------------------+---------------------------------------------------------------------| +| ~doc/index.org~ | Main: coordinate system, shapes, CSG, developer tools | +| ~doc/Rendering loop/index.org~ | 5-phase pipeline, multi-threaded paint | +| ~doc/Shading/index.org~ | Lambert shading, lights, distance attenuation | +| ~doc/CSG/index.org~ | Boolean ops via BSP trees | +| ~doc/Frustum culling/index.org~ | View frustum culling | +| ~doc/Near plane clip/index.org~ | Near-plane polygon clipping (straddling geometry) | +| ~doc/Global illumination/index.org~ | Progressive GI: lightmaps, bounces, convergence | +| ~doc/Perspective correct textures/index.org~ | Texture mapping math | +| ~doc/Agentic development/index.org~ | Stub: headless-toolkit docs moved to ~doc/index.org~ :: Agentic development | +| ~doc/Stereoscopic rendering/index.org~ | Side-by-side stereo: two passes, per-eye viewports, IPD | +| ~doc/BSP-tree painter's algorithm/index.org~ | BSP compile + rank traversal fixing average-Z sort | +| ~doc/SDF textures/index.org~ | SDF text: glyph fields, coverage window, TextCanvas | + +Regenerate all HTML: ~doc/export-docs.sh~ (add ~--check~ for rendered +screenshots of every page). diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/COPYING @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/TODO.org b/TODO.org new file mode 100644 index 0000000..33129ba --- /dev/null +++ b/TODO.org @@ -0,0 +1,93 @@ +* Add 3D mouse support +:PROPERTIES: +:CUSTOM_ID: add-3d-mouse-support +:END: + +* Demos +:PROPERTIES: +:CUSTOM_ID: demos +:END: +** Add more math formula examples to "Mathematical formulas" demo +:PROPERTIES: +:CUSTOM_ID: add-more-math-formula-examples +:END: + +* Performance +:PROPERTIES: +:CUSTOM_ID: performance +:END: +** Group identical Vertices into one during object slicing +Now system will need to compute each unique point in 3D only +once. Polygons can share coordinates. + +** Add dynamic resolution support +:PROPERTIES: +:CUSTOM_ID: add-dynamic-resolution-support +:END: ++ When there are fast-paced scenes, dynamically and temporarily reduce + image resolution if needed to maintain desired FPS. + +** Add object fading based on view distance +:PROPERTIES: +:CUSTOM_ID: add-object-fading-view-distance +:END: +Goal: make it easier to distinguish nearby objects from distant ones. + +** Add polygon reduction based on view distance (LOD) +:PROPERTIES: +:CUSTOM_ID: add-polygon-reduction-lod +:END: + +** Compute global illumination at progressive resolutions + +At startup, initially compute global illumination using very coarse +lightmap. Then progressively keep recomputing it with finer and finer +level of detail. Once lightmap stabilizes, stop lightmap computation +until there is change in the scene. + +* Features +:PROPERTIES: +:CUSTOM_ID: features +:END: +** Make it possible to configure field of view (FOV) +** Add collision detection (physics engine) +* Add clickable vertexes +:PROPERTIES: +:CUSTOM_ID: add-clickable-vertexes +:END: + +Circular areas with radius. Can be visible, partially transparent or +invisible. + +Use them in 3D graph demo. Clicking on vertexes should place marker +and information billboard showing values at given XYZ location. + +Add formula textbox display on top of 3D graph. +- Consider making separate formula explorer app where formula will be + editable and there will be gallery of pre-vetted formulas. + - make this app under Aukio parent project. + - Consider integrating with FriCAS or similar CAS software so that + formula parsing and computation happens there. + ++ Study and apply where applicable + ++ Read this as example, and apply improvements/fixes where applicable: + http://blog.rogach.org/2015/08/how-to-create-your-own-simple-3d-render.html + ++ Improve triangulation. Read: https://ianthehenry.com/posts/delaunay/ + +* Aukio 3D Demos + +** Text editors demo + ++ Improve focus handling: + + Perhaps add shortcut to navigate world without exiting entire + stack of focus. + + Possibility to retain and reuse recently focused elements. + + Store user location in the world and view direction with the + focused window. So that when returning focus to far away object, + user is redirected also to proper location in the world. + ++ Possibility to store recently visited locations in the world and + return to them. + diff --git a/Tools/Open with IntelliJ IDEA b/Tools/Open with IntelliJ IDEA new file mode 100755 index 0000000..304bf94 --- /dev/null +++ b/Tools/Open with IntelliJ IDEA @@ -0,0 +1,54 @@ +#!/bin/bash + +# This script launches IntelliJ IDEA with the current project +# directory. The script is designed to be run by double-clicking it in +# the GNOME Nautilus file manager. + +# First, we change the current working directory to the directory of +# the script. + +# "${0%/*}" gives us the path of the script itself, without the +# script's filename. + +# This command basically tells the system "change the current +# directory to the directory containing this script". + +cd "${0%/*}" + +# Then, we move up one directory level. +# The ".." tells the system to go to the parent directory of the current directory. +# This is done because we assume that the project directory is one level up from the script. +cd .. + +# Now, we use the 'setsid' command to start a new session and run +# IntelliJ IDEA in the background. 'setsid' is a UNIX command that +# runs a program in a new session. + +# The command 'idea .' opens IntelliJ IDEA with the current directory +# as the project directory. The '&' at the end is a UNIX command that +# runs the process in the background. The '> /dev/null' part tells +# the system to redirect all output (both stdout and stderr, denoted +# by '&') that would normally go to the terminal to go to /dev/null +# instead, which is a special file that discards all data written to +# it. + +setsid idea . &>/dev/null & + +# The 'disown' command is a shell built-in that removes a shell job +# from the shell's active list. Therefore, the shell will not send a +# SIGHUP to this particular job when the shell session is terminated. + +# '-h' option specifies that if the shell receives a SIGHUP, it also +# doesn't send a SIGHUP to the job. + +# '$!' is a shell special parameter that expands to the process ID of +# the most recent background job. +disown -h $! + + +sleep 2 + +# Finally, we use the 'exit' command to terminate the shell script. +# This command tells the system to close the terminal window after +# IntelliJ IDEA has been opened. +exit diff --git a/Tools/Update web site b/Tools/Update web site new file mode 100755 index 0000000..e80b377 --- /dev/null +++ b/Tools/Update web site @@ -0,0 +1,101 @@ +#!/bin/bash +cd "${0%/*}"; if [ "$1" != "T" ]; then gnome-terminal -e "'$0' T"; exit; fi; + +cd .. + +# Function to export org to html using emacs in batch mode +export_org_to_html() { + local org_file=$1 + local dir=$(dirname "$org_file") + local base=$(basename "$org_file" .org) + ( + cd "$dir" || return 1 + local html_file="${base}.html" + if [ -f "$html_file" ]; then + rm -f "$html_file" + fi + echo "Exporting: $org_file → $dir/$html_file" + emacs --batch -l ~/.emacs --visit="${base}.org" --funcall=org-html-export-to-html --kill + if [ $? -eq 0 ]; then + echo "✓ Successfully exported $org_file" + else + echo "✗ Failed to export $org_file" + return 1 + fi + ) +} + +export_org_files_to_html() { + echo "🔍 Searching for .org files in doc/ ..." + echo "=======================================" + + mapfile -t ORG_FILES < <(find doc -type f -name "*.org" | sort) + + if [ ${#ORG_FILES[@]} -eq 0 ]; then + echo "❌ No .org files found!" + return 1 + fi + + echo "Found ${#ORG_FILES[@]} .org file(s):" + printf '%s\n' "${ORG_FILES[@]}" + echo "=======================================" + + SUCCESS_COUNT=0 + FAILED_COUNT=0 + + for org_file in "${ORG_FILES[@]}"; do + export_org_to_html "$org_file" + if [ $? -eq 0 ]; then + ((SUCCESS_COUNT++)) + else + ((FAILED_COUNT++)) + fi + done + + echo "=======================================" + echo "📊 SUMMARY:" + echo " ✓ Successful: $SUCCESS_COUNT" + echo " ✗ Failed: $FAILED_COUNT" + echo " Total: $((SUCCESS_COUNT + FAILED_COUNT))" + echo "" +} + +build_visualization_graphs() { + rm -rf doc/graphs/ + mkdir -p doc/graphs/ + + javainspect -j target/aukio-3d-*-SNAPSHOT.jar -d doc/graphs/ -n "All classes" -t png -ho + javainspect -j target/aukio-3d-*-SNAPSHOT.jar -d doc/graphs/ -n "GUI" -t png -w "eu.svjatoslav.aukio.e3d.gui.*" -ho + javainspect -j target/aukio-3d-*-SNAPSHOT.jar -d doc/graphs/ -n "Raster engine" -t png -w "eu.svjatoslav.aukio.e3d.renderer.raster.*" -ho + + meviz index -w doc/graphs/ -t "Aukio 3D classes" +} + +# Build project jar file and JavaDocs +mvn clean package + +# Put generated JavaDoc HTML files to documentation directory +rm -rf doc/apidocs/ +cp -r target/apidocs/ doc/ + +# Publish Emacs org-mode files into HTML format +export_org_files_to_html + +# Generate nice looking code visualization diagrams +build_visualization_graphs + + +## Upload assembled documentation to server +echo "📤 Uploading to server..." +rsync -avz --delete -e 'ssh -p 10006' doc/ \ + n0@www3.svjatoslav.eu:/mnt/big/projects/aukio-3d/ + +if [ $? -eq 0 ]; then + echo "✓ Upload completed successfully!" +else + echo "✗ Upload failed!" +fi + +echo "" +echo "Press ENTER to close this window." +read diff --git a/doc/Agentic development/Golden workflow.svg b/doc/Agentic development/Golden workflow.svg new file mode 100644 index 0000000..aa0e7fb --- /dev/null +++ b/doc/Agentic development/Golden workflow.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Snapshot.render + scene + pose + + + + + + goldens/*.png + committed reference + + + + + GoldenImage + .compare + + + + + PASS + exit 0 + + + + + FAIL, exit 1 + + diff PNG to /tmp + + + + + bug? fix code + intended? run + --update + + + + regenerates reference + + tolerance: per-channel delta + max differing-pixel fraction — shading is deterministic, keep both tight + diff --git a/doc/Agentic development/Headless lanes.svg b/doc/Agentic development/Headless lanes.svg new file mode 100644 index 0000000..1303a80 --- /dev/null +++ b/doc/Agentic development/Headless lanes.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ViewPanel + window + render thread + camera from user input + + + + Snapshot.render() + no window, no display + camera from pose string + + + + SAME pipeline + transform + ↓ + sort by Z + ↓ + paint + + + + screen + BufferStrategy + + + BufferedImage + → PNG (Snapshot.save) + → PixelAssertions + → GoldenImage + + + + + + + + + + + identical results + headless rendering drives the very same code the window uses — a test render IS the real render + diff --git a/doc/Agentic development/Pixel assertion.svg b/doc/Agentic development/Pixel assertion.svg new file mode 100644 index 0000000..a9218f6 --- /dev/null +++ b/doc/Agentic development/Pixel assertion.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + rendered frame (640×480) + + + + + + painted + painted + + + + region (0.15, 0.45) → (0.85, 1.0) + + + + hole + + + + PixelAssertions + unpaintedFraction(img, + bg=0x000000, + 0.15, 0.45, + 0.85, 1.0) + counts pixels that still + equal the background + → 0.017 > 0.01 FAIL + + + + + sentinel background: "nothing rendered here" is unambiguous, even in dark scenes + diff --git a/doc/Agentic development/diff-example.png b/doc/Agentic development/diff-example.png new file mode 100644 index 0000000..941d5ca Binary files /dev/null and b/doc/Agentic development/diff-example.png differ diff --git a/doc/Agentic development/snapshot-example.png b/doc/Agentic development/snapshot-example.png new file mode 100644 index 0000000..92ea03c Binary files /dev/null and b/doc/Agentic development/snapshot-example.png differ diff --git a/doc/CSG/BSP tree.svg b/doc/CSG/BSP tree.svg new file mode 100644 index 0000000..eb89c2c --- /dev/null +++ b/doc/CSG/BSP tree.svg @@ -0,0 +1,45 @@ + + + + + + + + + Plane P₁ + + + + + + + + + front + back + + + + Front (P₂) + + + + Back (P₃) + + + + + + + + + + + leaf + + leaf + + + Each plane divides space into front (normal side) and back (opposite) + Polygons are classified and split at each partitioning plane + diff --git a/doc/CSG/CSG demo.png b/doc/CSG/CSG demo.png new file mode 100644 index 0000000..2275350 Binary files /dev/null and b/doc/CSG/CSG demo.png differ diff --git a/doc/CSG/CSG intersect.svg b/doc/CSG/CSG intersect.svg new file mode 100644 index 0000000..a912f81 --- /dev/null +++ b/doc/CSG/CSG intersect.svg @@ -0,0 +1,41 @@ + + + + + + + + + + Input + + A + + B + + + ∩ + intersect + + + + + + + Result: A ∩ B + + + + + + + + + + + + Find overlap + between both + Only shared volume + remains + diff --git a/doc/CSG/CSG operations.svg b/doc/CSG/CSG operations.svg new file mode 100644 index 0000000..3f73cbe --- /dev/null +++ b/doc/CSG/CSG operations.svg @@ -0,0 +1,37 @@ + + + + + + + + + Input + + A + + B + + + − + subtract + + + + + + + Result: A − B + + + + + + cavity + + + B is the "cutter" + carves out of A + Cube with cavity + interior faces visible + diff --git a/doc/CSG/CSG union.svg b/doc/CSG/CSG union.svg new file mode 100644 index 0000000..f1eedec --- /dev/null +++ b/doc/CSG/CSG union.svg @@ -0,0 +1,38 @@ + + + + + + + + Input + + A + + B + + + + + union + + + + + + + Result: A + B + + + + + + removed + + + Keeps all geometry + from both shapes + Single combined volume + interior faces removed + diff --git a/doc/CSG/Polygon clipping.svg b/doc/CSG/Polygon clipping.svg new file mode 100644 index 0000000..41de628 --- /dev/null +++ b/doc/CSG/Polygon clipping.svg @@ -0,0 +1,39 @@ + + + + + + + + Polygon crosses plane + + + plane + + + + + → + split + + + Split into fragments + + + + + front + + + + back + + + + + + new edge + + + Spanning polygons are split; each fragment goes to its respective subtree + diff --git a/doc/CSG/index.org b/doc/CSG/index.org new file mode 100644 index 0000000..ebdbad6 --- /dev/null +++ b/doc/CSG/index.org @@ -0,0 +1,284 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Constructive Solid Geometry - Aukio 3D +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \usepackage[none]{hyphenat} + +#+OPTIONS: H:20 num:20 +#+OPTIONS: author:nil + +#+HTML_HEAD: + +[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]] + +* What is CSG? +:PROPERTIES: +:CUSTOM_ID: what-is-csg +:ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890 +:END: + +*Constructive Solid Geometry* (CSG) is a modeling technique that builds +complex 3D shapes by combining simpler primitives using boolean +operations. Instead of manually creating every vertex and face, you +define shapes as the result of operations like "merge these two cubes" +or "carve a hole using this sphere." + +CSG is particularly powerful for: +- *Procedural modeling* — generate complex geometry algorithmically +- *CAD/CAM applications* — define parts as combinations of primitives +- *Game development* — create architectural elements, holes, cavities +- *Rapid prototyping* — iterate on designs by adjusting operations + +The three fundamental CSG operations are: + +| Operation | Symbol | Result | +|-------------+--------+-------------------------------------------| +| Subtract | A - B | A with B carved out (holes, cavities) | +| Union | A + B | Combined volume (both shapes merged) | +| Intersect | A ∩ B | Volume where both overlap | + +See the [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#csg-demo][CSG demo]] for an interactive visualization. + +* The Three Operations +:PROPERTIES: +:CUSTOM_ID: the-three-operations +:END: + +#+attr_html: :class responsive-img +#+attr_latex: :width 1000px +[[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#csg-demo][file:CSG%20demo.png]] + +The screenshot above shows all three operations displayed left to right: +subtract (green cube with spherical cavity), union (merged green and +orange shapes), and intersect (only the overlapping region in blue). + +The diagrams below use the same green cube (A) and orange sphere (B) as +the screenshot above. Each operation transforms these inputs differently, +producing the results shown from left to right in the image. + +** Subtract (A - B) +:PROPERTIES: +:CUSTOM_ID: subtract-operation +:END: + +#+INCLUDE: "CSG operations.svg" export html + +*Subtract* removes the orange sphere (B) from the green cube (A), carving +out a cavity. The diagram shows B acting as a "cutter" — where it overlaps +A, a hole is created. Interior faces *are preserved* and become visible, +allowing you to see inside the carved-out space (shown as the orange dashed +curve in the result). + +This matches the leftmost shape in the screenshot: a green cube with a +visible spherical hollow inside, showing the interior surfaces created by +the subtraction. + +This operation is ideal for creating: +- Holes and tunnels +- Carved-out spaces +- Hollow objects + +#+BEGIN_SRC java +SolidPolygonCube cube = new SolidPolygonCube(Point3D.origin(), 80, Color.GREEN); +SolidPolygonSphere sphere = new SolidPolygonSphere(Point3D.origin(), 60, 8, Color.ORANGE); + +cube.subtract(sphere); // cube now has a spherical cavity +#+END_SRC + +** Union (A + B) +:PROPERTIES: +:CUSTOM_ID: union-operation +:END: + +#+INCLUDE: "CSG union.svg" export html + +*Union* merges the green cube (A) and orange sphere (B) into one continuous +volume. The diagram shows both shapes combining — the interior seam (where +they overlap) is removed, creating a single solid surface with no internal +boundaries (indicated by the dashed blue line labeled "removed"). + +This corresponds to the center shape in the screenshot: both green and +orange colors present but seamlessly joined, forming one unified object. + +#+BEGIN_SRC java +SolidPolygonCube cube = new SolidPolygonCube(Point3D.origin(), 80, Color.GREEN); +SolidPolygonSphere sphere = new SolidPolygonSphere(Point3D.origin(), 60, 8, Color.ORANGE); + +cube.union(sphere); // cube now contains the merged result +#+END_SRC + +** Intersect (A ∩ B) +:PROPERTIES: +:CUSTOM_ID: intersect-operation +:END: + +#+INCLUDE: "CSG intersect.svg" export html + +*Intersect* keeps only the volume where the green cube (A) and orange +sphere (B) overlap — the region that is inside *both* shapes +simultaneously. The diagram shows this as the blue-shaded area: the +portion of the sphere that fits within the cube boundaries. Everything +else is discarded. + +This is the rightmost shape in the screenshot: only the overlapping +portion remains, showing which parts of space were occupied by both the +cube and sphere at the same time. + +This operation is useful for: +- Creating shapes constrained by multiple boundaries +- Finding collision regions +- Trimming geometry to fit within bounds + +#+BEGIN_SRC java +SolidPolygonCube cube = new SolidPolygonCube(Point3D.origin(), 80, Color.GREEN); +SolidPolygonSphere sphere = new SolidPolygonSphere(Point3D.origin(), 60, 8, Color.ORANGE); + +cube.intersect(sphere); // only the overlapping region remains +#+END_SRC + +* BSP Tree Algorithm +:PROPERTIES: +:CUSTOM_ID: bsp-tree-algorithm +:END: + +CSG boolean operations are implemented using *Binary Space Partitioning* +(BSP) trees. A BSP tree recursively divides 3D space using planes, +creating a hierarchical structure that enables efficient polygon clipping +and spatial queries. + +** BSP Tree Structure +:PROPERTIES: +:CUSTOM_ID: bsp-tree-structure +:END: + +#+INCLUDE: "BSP tree.svg" export html + +Each BSP node contains: +- A *partitioning plane* that divides space into two half-spaces +- *Polygons* that lie exactly on this plane (coplanar) +- *Front* subtree — polygons on the same side as the plane's normal +- *Back* subtree — polygons on the opposite side + +** Key BSP Operations +:PROPERTIES: +:CUSTOM_ID: key-bsp-operations +:END: + +The BSP tree provides three core operations that enable CSG: + +| Operation | Description | +|----------------+--------------------------------------------------| +| =invert()= | Flip all normals, swap front/back children | +| =clipTo(tree)= | Remove polygons inside the other tree's solid | +| =addPolygons()= | Insert new polygons, splitting at planes | + +*Invert* is fundamental to CSG. By flipping inside/outside, we can +transform subtraction and intersection into variations of clipping: + +- **Subtract** = invert A, clip against B, add B's clipped parts, invert back +- **Intersect** = invert A, clip B against A, invert B, clip A against B, combine, invert A back + +** Polygon Clipping +:PROPERTIES: +:CUSTOM_ID: polygon-clipping +:END: + +When a polygon crosses a partitioning plane, it's *split* into two +fragments: + +#+INCLUDE: "Polygon clipping.svg" export html + +This recursive splitting ensures that all polygons are cleanly classified +as entirely in front, entirely behind, or exactly on a plane — never +"spanning" across. + +* Using CSG in Aukio 3D +:PROPERTIES: +:CUSTOM_ID: using-csg-in-aukio-3d +:END: + +CSG operations are methods on [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html][AbstractCompositeShape]]. They modify the +shape *in-place* — the result replaces the original geometry. + +** Basic Usage +:PROPERTIES: +:CUSTOM_ID: basic-usage +:END: + +#+BEGIN_SRC java +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.*; + +// Create two shapes +SolidPolygonCube cube = new SolidPolygonCube(Point3D.origin(), 100, Color.GREEN); +SolidPolygonSphere sphere = new SolidPolygonSphere(Point3D.origin(), 70, 12, Color.ORANGE); + +// Perform CSG operations (in-place modification) +cube.subtract(sphere); // Cube with spherical cavity +// or +cube.union(sphere); // Merged shape +// or +cube.intersect(sphere); // Only overlapping region + +// Add to scene +shapes.addShape(cube.setBackfaceCulling(true)); +#+END_SRC + +** Child Handling Behavior +:PROPERTIES: +:CUSTOM_ID: child-handling +:END: + +CSG operations only affect *SolidPolygon* geometry. Other children are +preserved as objects: + +| Child Type | Union | Subtract | Intersect | +|-----------------------+------------------+------------------+------------------| +| SolidPolygon (this) | Replaced with result | Replaced with result | Replaced with result | +| SolidPolygon (other) | Merged into result | Discarded (cutter) | Discarded | +| Line, TextCanvas (this) | Preserved | Preserved | Preserved | +| Line, TextCanvas (other) | Merged into this shape | Discarded | Discarded | +| Nested composite | Preserved as object — but see below | same | same | + +*Nested composites are not CSG-safe.* Polygon extraction recurses into +them, so their SolidPolygons are included in the BSP result — while the +nested composite object itself is also preserved, duplicating that +geometry in the render. Apply CSG to flat composites, or extract the +nested polygons first. + +This allows you to attach labels, decorations, or wireframe overlays to +shapes without them being affected by CSG operations (for union, the +other shape's decorations are copied over too). + +** Important Notes +:PROPERTIES: +:CUSTOM_ID: important-notes +:END: + +1. *Shapes are modified in-place*. The original geometry is replaced. + Clone shapes beforehand if you need to preserve the originals. + +2. *CSG works on SolidPolygon children only*. TexturedTriangle and other + shape types are not processed. + +3. *Result quality depends on mesh density*. Low-polygon inputs may + produce visible artifacts at intersection boundaries. Use higher + subdivision counts for smoother results. + +4. *Backface culling is recommended*. CSG results often have internal + faces from the cutting operation. Enable culling to hide backfaces: + =shape.setBackfaceCulling(true)= + +* Related Classes +:PROPERTIES: +:CUSTOM_ID: related-classes +:END: + +| Class | Purpose | +|--------------------------+------------------------------------------------------| +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/BspTree.html][BspTree]] | BSP tree for spatial partitioning and CSG operations | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html][Plane]] | Partitioning plane used by BSP nodes | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] | Polygon shape processed by CSG | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html][AbstractCompositeShape]] | Base class with union/subtract/intersect methods | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.html][SolidPolygonMesh]] | Custom polygon mesh for arbitrary geometry | +| SolidPolygon* primitives | See [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#shape-gallery][Shape Gallery]] for all available shapes | diff --git a/doc/Coordinate system.svg b/doc/Coordinate system.svg new file mode 100644 index 0000000..4497bf3 --- /dev/null +++ b/doc/Coordinate system.svg @@ -0,0 +1,18 @@ + + + + + + X + right (+) / left (-) + + + Y + down (+) / up (-) + + + Z + away (+) / towards (-) + Origin + (0, 0, 0) + \ No newline at end of file diff --git a/doc/Depth buffer/index.org b/doc/Depth buffer/index.org new file mode 100644 index 0000000..2f2c75d --- /dev/null +++ b/doc/Depth buffer/index.org @@ -0,0 +1,130 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Depth Buffer - Aukio 3D +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \setlength{\parindent}{15pt} +#+LATEX_HEADER: \usepackage{palatino} +#+LATEX_HEADER: \usepackage{charter} +#+HTML_HEAD: + +[[file:../index.html#outline-container-depth-buffer][<- Back to index]] + +* Per-pixel visibility +:PROPERTIES: +:CUSTOM_ID: per-pixel +:END: + +The engine resolves visibility with a depth buffer, not paint order. +Every rasterized triangle carries a per-pixel depth quantity =zw = +1/z= (camera-space), interpolated linearly across each span — =1/z= is +affine in screen space, so it rides the same edge interpolators as the +texture gradients. A fragment wins a pixel only where + +#+BEGIN_EXAMPLE +zw > stored - margin * zw^2 (margin = 0 by default) +#+END_EXAMPLE + +Default margin 0 is *strict depth*: the nearer fragment always wins, +regardless of paint order, so overlapping depth ranges — a floor tile +extending under furniture, a wall seen through a doorway — come out +correct per pixel. A nonzero margin (= RenderingContext.DEPTH_MARGIN_DZ =, +=-Daukio.zbuffer.margin=, world units) re-opens a tolerance window +*behind* the stored depth for near-coplanar pairs; any nonzero window +re-imports per-triangle sort errors into per-pixel occlusion, which is +why the default is strict. + +The depth buffer is allocated once per frame context +(=RenderingContext.depth=, float per pixel) and cleared per tile +together with the pixel buffer. + +* Two passes +:PROPERTIES: +:CUSTOM_ID: two-passes +:END: + +=RenderAggregator.paintSorted= paints the sorted queue in two passes: + +1. *Opaque pass* — opaque-class triangles, iterated front-to-back (the + queue is back-to-front, so reversed), depth test + depth write. + Front-to-back order is a pure performance hint: hidden fragments + die on the depth test *before* the texture fetch (early-z). +2. *Alpha pass* — alpha-class triangles (translucent solid polygons, + alpha-carrying textures, SDF text), iterated back-to-front in queue + order, depth test but *no depth write*. Translucency never + occludes, and overlapping translucent surfaces keep painter-coherent + mutual order. + +A shape's class comes from its paint color or texture: solid polygons +with =alpha = 255= are opaque, anything translucent is alpha-class; +textured triangles are alpha-class when the texture has alpha or is an +SDF mask. + +* Which shapes carry depth +:PROPERTIES: +:CUSTOM_ID: shapes +:END: + +- =TexturedTriangle= — opaque or alpha class by texture. +- =SolidPolygon= — depth-tested since 2026-09-17; opaque when its + (possibly shaded) color is fully opaque, translucent otherwise. + Near-plane-clipped quads fan-triangulate with depth like any other + triangles. +- =LightmappedTriangle= — a textured triangle, so the same rules. +- =Line=, =Billboard=, =GlowingPoint= — no depth by design: they are + 2D overlays (wireframes, markers, sprites) and always paint on top, + in the alpha pass. + +Because every occluder writes depth, scene code no longer needs any +ordering structure: composites just fan-triangulate their polygons. +(=LightmappedCompositeShape= exists only to wrap polygons as lightmap +carriers for the GI system, not to order them.) + +* Hi-Z occlusion pyramid +:PROPERTIES: +:CUSTOM_ID: hi-z +:END: + +After each successful paint, =ViewPanel= builds a Hi-Z pyramid from +the depth buffer (gui/HiZPyramid): 8-pixel tiles pooled upward, +each tile storing the *minimum* =zw= (farthest written depth — +max-pooling would store the nearest occluder and wrongly cull geometry +visible between near gaps). Next frame, =TriangleMeshBlock= projects +its world AABB's 8 corners and, when the nearest corner is still +behind the pyramid's stored depth, skips the whole block before any +per-triangle work. + +The test is conservative by construction (min-pooling plus sky pixels +at =-inf=), so it never culls visible geometry; wrong culls under +camera motion self-heal in one frame. Knobs: =-Daukio.hiz.margin=0.02=, +kill switch =-Daukio.hiz=false=. Headless snapshots never build the +pyramid, so golden renders are structurally unaffected. Stereo skips +the test (the pyramid is mono). + +* Determinism and depth dumps +:PROPERTIES: +:CUSTOM_ID: determinism +:END: + +The renderer is bit-deterministic: same scene and camera give +bit-identical pixels across runs, which is what the golden-image +regression tests compare. =Snapshot= (the headless toolkit) supports +=-Daukio.zbuffer.dumpDepth=path.png= to write a grayscale depth map +alongside the color image — useful when hunting depth-window bugs +(bisect those with =-Daukio.zbuffer.margin=0=). + +* Related classes +:PROPERTIES: +:CUSTOM_ID: related-classes +:END: + +| Class | Role | +|----------------------+----------------------------------------------------------| +| =RenderingContext= | =depth= buffer, =depthPass=, =DEPTH_MARGIN_DZ= constant | +| =RenderAggregator= | =paintSorted= two-pass driver, queue sort | +| =TexturedTriangle= | Z span writers (perspective and affine) | +| =SolidPolygon= | flat-color Z span writer, two-pass classification | +| =HiZPyramid= | temporal whole-block occlusion culling | +| =ViewPanel= | per-tile depth clear, pyramid rebuild after paint | + +[[file:../index.html#outline-container-depth-buffer][Back to main documentation]] diff --git a/doc/Developer tools/Developer tools.png b/doc/Developer tools/Developer tools.png new file mode 100644 index 0000000..825b0de Binary files /dev/null and b/doc/Developer tools/Developer tools.png differ diff --git a/doc/Developer tools/Render alternative segments.png b/doc/Developer tools/Render alternative segments.png new file mode 100644 index 0000000..e2bd569 Binary files /dev/null and b/doc/Developer tools/Render alternative segments.png differ diff --git a/doc/Developer tools/Render polygon borders.png b/doc/Developer tools/Render polygon borders.png new file mode 100644 index 0000000..5ec2182 Binary files /dev/null and b/doc/Developer tools/Render polygon borders.png differ diff --git a/doc/Developer tools/Show segment boundaries.png b/doc/Developer tools/Show segment boundaries.png new file mode 100644 index 0000000..01a1978 Binary files /dev/null and b/doc/Developer tools/Show segment boundaries.png differ diff --git a/doc/Developer tools/Thread timeline.png b/doc/Developer tools/Thread timeline.png new file mode 100644 index 0000000..dd1d378 Binary files /dev/null and b/doc/Developer tools/Thread timeline.png differ diff --git a/doc/Edge.svg b/doc/Edge.svg new file mode 100644 index 0000000..e9af1cf --- /dev/null +++ b/doc/Edge.svg @@ -0,0 +1,12 @@ + + + + + + + + V₁ + V₂ + V₃ + edge + diff --git a/doc/Example.png b/doc/Example.png new file mode 100644 index 0000000..7094240 Binary files /dev/null and b/doc/Example.png differ diff --git a/doc/Face triangle.svg b/doc/Face triangle.svg new file mode 100644 index 0000000..509c841 --- /dev/null +++ b/doc/Face triangle.svg @@ -0,0 +1,14 @@ + + + + + + + + + + V₁ + V₂ + V₃ + FACE + diff --git a/doc/Frustum culling/Frustum diagram.svg b/doc/Frustum culling/Frustum diagram.svg new file mode 100644 index 0000000..b59d4a8 --- /dev/null +++ b/doc/Frustum culling/Frustum diagram.svg @@ -0,0 +1,58 @@ + + + + + + + + + +Z + (view direction) + + + + Camera + + + + + + + + + + + + + + Near + + + + Far + + + + + + visible region + + + Top plane + Bottom plane + + + + ✓ + rendered + + + + ✗ + culled + + + + ✗ + culled + diff --git a/doc/Frustum culling/P-vertex AABB.svg b/doc/Frustum culling/P-vertex AABB.svg new file mode 100644 index 0000000..a3acfb8 --- /dev/null +++ b/doc/Frustum culling/P-vertex AABB.svg @@ -0,0 +1,38 @@ + + + + + + + + P-vertex: corner most aligned with plane normal + If P is behind the plane → entire AABB is outside + + + + Plane + + + inside frustum + + outside frustum + + + + + N + + + + inside + + + P + + + + outside + + + P + diff --git a/doc/Frustum culling/index.org b/doc/Frustum culling/index.org new file mode 100644 index 0000000..9c4a941 --- /dev/null +++ b/doc/Frustum culling/index.org @@ -0,0 +1,177 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Frustum & View Frustum Culling - Aukio 3D +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \usepackage[none]{hyphenat} + +#+OPTIONS: H:20 num:20 +#+OPTIONS: author:nil + +#+HTML_HEAD: + +[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]] + +* Frustum & View Frustum Culling +:PROPERTIES: +:CUSTOM_ID: frustum-view-frustum-culling +:END: + +#+INCLUDE: "Frustum diagram.svg" export html + +The *view frustum* is a truncated pyramid-shaped volume that represents +everything the camera can see. Objects completely outside this volume are +skipped during rendering — a powerful optimization called *frustum culling*. + +** The Six Frustum Planes +:PROPERTIES: +:CUSTOM_ID: frustum-planes +:END: + +The frustum is defined by six clipping planes: + +| Plane | Purpose | +|---------+--------------------------------------------| +| Left | Left edge of viewport | +| Right | Right edge of viewport | +| Top | Top edge of viewport (smaller Y in Y-down) | +| Bottom | Bottom edge of viewport (larger Y) | +| Near | Closest visible distance from camera | +| Far | Farthest visible distance from camera | + +Each plane divides 3D space into "inside" (visible) and "outside" +(culled). An object must pass all six plane tests to be considered +potentially visible. + +** Frustum Culling vs Backface Culling +:PROPERTIES: +:CUSTOM_ID: frustum-vs-backface-culling +:END: + +These are complementary optimizations at different levels: + +| Optimization | Level | What it skips | +|-----------------+--------------+----------------------------------| +| Frustum culling | Object level | Entire composite shapes + children | +| Backface culling | Polygon level | Individual triangles facing away | + +*Frustum culling* happens first during the transform phase — entire +object trees are skipped with a single bounding box test. *Backface +culling* happens later during rasterization — individual triangles +are checked before being drawn. + +For best performance, use both: organize your scene with composite +shapes for effective frustum culling, and enable backface culling on +closed meshes. + +* How Frustum Culling Works in Aukio 3D +:PROPERTIES: +:CUSTOM_ID: frustum-culling-implementation +:END: + +Frustum culling is applied automatically to all [[../index.org#mesh][composite shapes]] +during Phase 1 (transform) of the [[../Rendering loop/][rendering loop]]: + +1. *Update frustum*: Compute 6 planes from camera FOV and viewport size +2. *For each composite shape*: + - Get its [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Box.html][Axis-Aligned Bounding Box (AABB)]] + - Transform all 8 corners to view space + - Test against frustum using [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Frustum.html#intersectsAABB][intersectsAABB()]] + - If outside: skip the entire composite and all children + - If inside: continue transforming children + +(The root composite itself is never tested — it is always rendered.) + +** The AABB Intersection Algorithm +:PROPERTIES: +:CUSTOM_ID: aabb-intersection-algorithm +:END: + +The intersection test uses an optimized "P-vertex" approach: + +#+INCLUDE: "P-vertex AABB.svg" export html + +For each plane, instead of testing all 8 corners of the bounding box, +we test only the *P-vertex* — the corner most aligned with the plane +normal. If this "best" corner is behind the plane, the entire box must +be outside the frustum. + +- Plane normal points *into* the frustum (toward visible region) +- P-vertex: select corner based on normal direction + - If normal.x > 0 → use maxX (rightmost corner) + - If normal.x < 0 → use minX (leftmost corner) + - Same logic for Y and Z +- Test: =dot(normal, P-vertex) < distance= → outside + +This reduces from 48 tests (8 corners × 6 planes) to just 6 tests per +object. + +* Performance Benefits +:PROPERTIES: +:CUSTOM_ID: frustum-performance +:END: + +Frustum culling can dramatically improve performance for large scenes: + +- *High cull % (60-90%)*: Excellent — most objects skipped entirely +- *Medium cull % (20-60%)*: Moderate benefit +- *Low cull % (0-20%)*: Limited benefit — most objects visible + +A composite shape that is culled skips: +- Transforming all its children +- Computing bounding boxes for children +- All polygon-level operations (backface culling, rasterization) + +Open Developer Tools (F12) to see real-time [[../index.org#frustum-culling-statistics][frustum culling statistics]]. + +* Scene Design for Effective Culling +:PROPERTIES: +:CUSTOM_ID: frustum-scene-design +:END: + +Frustum culling works best when you organize your scene into +well-defined composite shapes: + +#+BEGIN_SRC java +// Good: Each building is a separate composite +AbstractCompositeShape cityBlock = new AbstractCompositeShape(); +for (Building building : buildings) { + AbstractCompositeShape buildingComposite = new AbstractCompositeShape(); + buildingComposite.addShape(buildingWalls); + buildingComposite.addShape(buildingRoof); + buildingComposite.addShape(buildingInterior); + cityBlock.addShape(buildingComposite); +} + +// Less effective: Everything in one giant composite +AbstractCompositeShape allObjects = new AbstractCompositeShape(); +allObjects.addShape(building1Walls); +allObjects.addShape(building1Roof); +allObjects.addShape(building2Walls); +// ... hundreds of shapes directly in root +#+END_SRC + +*Best practices:* + +- Use composites to group objects that occupy a bounded region of space +- Keep bounding boxes tight (don't add distant objects to the same composite) +- Nest composites hierarchically for multi-level culling (city → block → building) +- Call [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractShape.html#invalidateBounds()][invalidateBounds()]] after moving shapes — the bounding box is + recomputed lazily on next use + +* Technical Details +:PROPERTIES: +:CUSTOM_ID: frustum-technical-details +:END: + +The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Frustum.html][Frustum]] class: + +- Computes planes in *view space* (camera at origin, looking along +Z) +- FOV derived from =projectionScale = width / 3= (≈112° horizontal FOV) +- Default clip distances: Near = 1.0, Far = 10000.0 +- Planes stored in [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html][Hesse normal form]]: (normal vector, distance) + +The frustum is updated once per render pass in +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.html][ShapeCollection.transformShapesBegin()]], from the camera state and +the stereo viewport width — in stereo mode each eye gets its own +frustum, so the update runs twice per frame. diff --git a/doc/Global illumination/Bounce estimator.svg b/doc/Global illumination/Bounce estimator.svg new file mode 100644 index 0000000..0d1972e --- /dev/null +++ b/doc/Global illumination/Bounce estimator.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + surface + + + + + + + P + + normal + + + + cosine-weighted + hemisphere + + + + + + + + + Q + + + + + light + + + + shadow ray: clear + + + + + + occluder + blocked + + + + at Q: direct light (cached shadow bits) + + Q's current indirect estimate + + + + P's target = (albedo / π) x (direct + indirect) + blended in with an exponential moving average + + one sample per texel per visit: one shadow ray + one bounce ray — bounce light ripples deeper every sweep + diff --git a/doc/Global illumination/GI pipeline.svg b/doc/Global illumination/GI pipeline.svg new file mode 100644 index 0000000..0f63023 --- /dev/null +++ b/doc/Global illumination/GI pipeline.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + scene snapshot + triangles + lights + + BVH + + + GI workers + Monte Carlo + sweeps + + + lightmaps + per-texel indirect + + shadow bits + + + composite + baseColor x light + double-buffered + swap + + + painter + plain texture + lookup + + + + + + + + + + bounce reads last sweep's estimate + + zero ray casting + on render threads + diff --git a/doc/Global illumination/Global illumination.png b/doc/Global illumination/Global illumination.png new file mode 100644 index 0000000..9909193 Binary files /dev/null and b/doc/Global illumination/Global illumination.png differ diff --git a/doc/Global illumination/Lightmap mapping.svg b/doc/Global illumination/Lightmap mapping.svg new file mode 100644 index 0000000..2d82fea --- /dev/null +++ b/doc/Global illumination/Lightmap mapping.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + invalid half (u+v > 1) + filled from neighbors so sampling + near the hypotenuse stays clean + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + one texel = one surface + patch, sampled at center + + + + a (u=0, v=0) + + b (u=1, v=0) + + c (u=0, v=1) + + + + e1 = b − a + + e2 = c − a + + + + world(u,v) = a + e1·u + e2·v + texels = edge / unitsPerTexel + + every texel owns a fixed patch of the triangle — shadows and gradients live INSIDE the surface + diff --git a/doc/Global illumination/gi-converged.png b/doc/Global illumination/gi-converged.png new file mode 100644 index 0000000..1f81bfd Binary files /dev/null and b/doc/Global illumination/gi-converged.png differ diff --git a/doc/Global illumination/gi-flat.png b/doc/Global illumination/gi-flat.png new file mode 100644 index 0000000..f135bd2 Binary files /dev/null and b/doc/Global illumination/gi-flat.png differ diff --git a/doc/Global illumination/gi-start.png b/doc/Global illumination/gi-start.png new file mode 100644 index 0000000..6f7f3f0 Binary files /dev/null and b/doc/Global illumination/gi-start.png differ diff --git a/doc/Global illumination/index.org b/doc/Global illumination/index.org new file mode 100644 index 0000000..9568956 --- /dev/null +++ b/doc/Global illumination/index.org @@ -0,0 +1,251 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Global Illumination - Aukio 3D +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \usepackage[none]{hyphenat} + +#+OPTIONS: H:20 num:20 +#+OPTIONS: author:nil + +#+HTML_HEAD: + +[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]] + +* What global illumination adds +:PROPERTIES: +:CUSTOM_ID: what-gi-adds +:END: + +Plain per-polygon shading lights every polygon with one flat color: +no shadows, and surfaces that receive no direct light stay uniformly +dark. A room corner reads as a flat silhouette instead of a corner. + +#+attr_html: :class responsive-img +#+attr_latex: :width 640px +[[file:gi-flat.png]] + +*Aukio 3D* can optionally compute *global illumination* progressively +on background CPU threads: shadows appear, light pools under lamps +with smooth falloff, and colored light *bleeds* — a red sofa tints the +floor next to it red. All of it converges gradually over the first +seconds of a scene, then idles. + +Same camera, same house: flat shading (top) versus converged GI +(below). Note the soft shadow of the partition wall, the lamp glow on +the ceiling, and the subtle color variation across the floor. + +#+attr_html: :class responsive-img +#+attr_latex: :width 640px +[[file:gi-converged.png]] + +* The big idea: GI off the render path +:PROPERTIES: +:CUSTOM_ID: off-the-render-path +:END: + +Ray tracing is far too slow to run per frame in a software renderer, +so it doesn't: *the render loop never traces a single ray.* Painting a +lightmapped triangle is an ordinary texture lookup, exactly as fast as +any textured polygon. + +All the expensive work happens on dedicated low-priority worker threads +that continuously refine per-surface lighting values. Whenever the +values have improved enough, the workers regenerate each triangle's +*composite texture* (baseColor x total lighting) into a back buffer +and swap it in atomically — painters never see a half-updated texture. + +#+INCLUDE: "GI pipeline.svg" export html + +Because painters only read finished textures, frame rate is completely +decoupled from GI quality: you can crank lightmap resolution up and the +only cost is CPU time on the worker threads, not frame time. + +* Lightmaps: a texture per triangle +:PROPERTIES: +:CUSTOM_ID: lightmaps +:END: + +Flat shading can only color a polygon uniformly — shadows and gradients +need resolution *inside* the polygon. Every lightmapped triangle +therefore owns a small generated texture, its *lightmap*, whose texels +map onto the triangle surface by an affine rule: + +#+INCLUDE: "Lightmap mapping.svg" export html + +The triangle's UVs are pinned to (0,0), (1,0), (0,1), so the valid +texel region is the half where u+v <= 1; the other half of the square +texture is flood-filled from valid neighbors so that nearest sampling +near the hypotenuse never picks up garbage. + +Each texel stores two things, both written only by GI threads: + +- *Indirect irradiance* (RGB floats) — the accumulated bounced light. +- *Per-light visibility bits* — whether the last shadow ray from this + texel reached each lamp (cached so later queries cost nothing). + +Resolution is set in world units per texel +(=LightmappedCompositeShape.setLightmapUnitsPerTexel()=, default 12): a 100-unit +wall cell gets an 8x8 lightmap. Halving the units quadruples the +tracing work. + +*What you trace is what you see:* the composite texture the painter +samples is the lightmap itself, at native texel resolution — there is +no upscaling step. Shadow-edge smoothness comes from tracing at finer +resolution, never from interpolation. (An earlier bilinear-upscaling +pass produced visibly artificial results and was removed; finer texels +cost more CPU on the GI threads but look right.) + +* One sample: a shadow ray and a bounce ray +:PROPERTIES: +:CUSTOM_ID: one-sample +:END: + +The work list is flat: one item per lightmap texel (and one per plain +polygon, see below). Worker threads walk it round-robin, and every +visit to a texel casts exactly two rays from the texel's world +position, nudged slightly off the surface along the normal: + +1. *Shadow ray* toward a lamp. Answers visible/occluded, cached in the + texel's visibility bits. On a texel's *first* visit all lamps are + tested at once, so direct light and hard shadows appear after a + single sweep instead of trickling in lamp by lamp; later visits + re-test one lamp at a time, round-robin. +2. *Bounce ray* in a random cosine-weighted direction around the + normal. Wherever it lands (point Q), the sample reads Q's *direct* + lighting — using Q's cached shadow bits, no new shadow rays — plus + Q's *current indirect estimate*, and blends the sum into the texel's + own indirect value. + +#+INCLUDE: "Bounce estimator.svg" export html + +Reading Q's current indirect estimate instead of recursing is what +makes bounce light propagate: sweep 1 learns "Q is directly lit", +sweep 2 learns "P sees a lit Q", sweep 3 learns "R sees a lit P"... +Light ripples one surface deeper with every sweep, with no recursion +limit and no exponential ray explosion. The =1/pi= diffuse gain keeps +the feedback loop from diverging: without it the indirect term +amplifies itself and the scene saturates to white. + +* Progressive convergence +:PROPERTIES: +:CUSTOM_ID: convergence +:END: + +Monte Carlo samples are noisy, so blending happens at *two nested +levels*, both exponential moving averages: + +1. *Inner, per sample*: each texel blends every new bounce-ray result + into its indirect estimate with a constant weight (=alpha = 0.15=, + mode =fixed=). Every ray hit stays equally intensive forever — an + unlit area fades to darkness at the same rate a lit area brightens. + (The old =adaptive= mode, which decays alpha with sample count, is + still available; see the knobs below.) +2. *Outer, per composite update*: the value that reaches the screen is + a second EMA over the COMPLETE sum =ambient + direct + indirect=. + The texture can only move =e3d.gi.compositeAlpha= (default 0.2) of + the remaining distance per 500 ms update — so direct light, shadows + and bounce light all glide in together over a few seconds, and no + single-frame jump is possible by construction. + +The world starts at a *uniform medium irradiance* +(=e3d.gi.initialIrradiance=, default 128): the scene is visible from +the very first frame, then lit areas brighten and unlit areas sink to +darkness as the workers sweep — lights and shadows gradually become +distinguished instead of the old pitch-black start with a sudden flash +once the first sweep landed: + +#+attr_html: :class responsive-img +#+attr_latex: :width 640px +[[file:gi-start.png]] + +Consequences of the design: + +- *Hysteresis is free*: when a lamp moves or geometry changes, old + light fades out gradually instead of popping — the same pair of EMAs + that accumulates light also drains it. +- *Convergence detection*: per-sample deltas are pure noise (and with a + constant alpha they never settle), so the system watches the average + per-texel movement of the on-screen estimate; after five composite + updates below =e3d.gi.calmThreshold= (default 1.0 light unit) the + workers drop to a low duty cycle (~50% of sweep time, capped) instead + of burning CPU. Any scene or light change rebuilds the snapshot and + restarts full-speed tracing. +- *Despeckle*: at composite time the indirect channel is blended 50/50 + with the mean of its valid 4-neighbors, killing single-texel Monte + Carlo spikes without blurring real gradients. +- *Composite cadence*: textures regenerate at most every 500 ms — one + atomic swap per triangle, invisible to painters. + +* Plain polygons get GI too +:PROPERTIES: +:CUSTOM_ID: plain-polygons +:END: + +Surfaces that are not lightmapped (ordinary =SolidPolygon= with shading +enabled) still benefit, at per-polygon resolution, through the +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/GiLightProvider.html][GiLightProvider]] +interface that =GlobalIllumination= installs into the +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html][LightingManager]]: + +- =isLightVisible(polygon, light)= answers from cached shadow bits — + direct-light shadows fade in on flat-shaded geometry. +- =addIndirectLight(polygon, baseColor, result)= adds the polygon's + bounced-light estimate into the flat-shaded color. + +Both are called from parallel render-pool threads, so they only read +volatile caches — never trace. + +* Enabling GI +:PROPERTIES: +:CUSTOM_ID: enabling +:END: + +#+BEGIN_SRC java +// Per-texel lightmaps on composite geometry (the House demo setup): +LightmappedCompositeShape house = new LightmappedCompositeShape(); +house.setLightmappingEnabled(true); +house.setLightmapUnitsPerTexel(3.0); // fine texels: quality from traced rays + +// Start the workers (2 threads by default; more converge faster): +viewPanel.enableGlobalIllumination(4); +#+END_SRC + +Tuning knobs (system properties): + +| Property | Default | Effect | +|---------------------------+---------+-----------------------------------------| +| =e3d.gi.alphaMode= | fixed | =adaptive= decays the inner EMA alpha with sample count | +| =e3d.gi.alphaFloor= | 0.08 | adaptive-mode floor; higher adapts faster but noisier | +| =e3d.gi.compositeAlpha= | 0.2 | outer EMA: fade speed of the on-screen estimate per 500 ms update | +| =e3d.gi.initialIrradiance=| 128 | uniform medium start (0..255 light units) | +| =e3d.gi.calmThreshold= | 1.0 | convergence: avg estimate movement (light units) | +| =e3d.gi.despeckle= | true | neighbor-smoothing of indirect at composite time | +| =e3d.gi.debug= | false | sweep statistics to stdout | +| =e3d.gi.dumpLightmaps= | (unset) | dump composite lightmaps as PNGs to the given dir | + +* Limitations +:PROPERTIES: +:CUSTOM_ID: limitations +:END: + +- *Diffuse light only* — no specular bounce, no caustics. +- Polygon vertices are traced in composite-local space; scenes that put + non-identity transforms on composites are traced incorrectly. +- The bounce estimate is one ray deep per sample — correctness comes + from sweep-over-sweep propagation, so deeply indirect corners take + several sweeps to brighten. + +* Related Classes +:PROPERTIES: +:CUSTOM_ID: related-classes +:END: + +| Class | Purpose | +|----------------------+---------------------------------------------------------------| +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.html][GlobalIllumination]] | Progressive tracer: sweeps, EMA convergence, composite swaps | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.html][Lightmap]] | Per-triangle texel state + double-buffered composite textures | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedTriangle.html][LightmappedTriangle]] | Textured triangle whose texture is the GI composite | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.html][TriangleBvh]] | BVH over world triangles: nearest-hit and any-hit ray queries (Möller–Trumbore) | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/GiLightProvider.html][GiLightProvider]] | Cache-read interface feeding the flat-shading path | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.html][LightmappedCompositeShape]] | Wraps polygons into lightmapped triangles | diff --git a/doc/Mesh.svg b/doc/Mesh.svg new file mode 100644 index 0000000..8a20f46 --- /dev/null +++ b/doc/Mesh.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + triangulated + section + + diff --git a/doc/Near plane clip/Clip algorithm.svg b/doc/Near plane clip/Clip algorithm.svg new file mode 100644 index 0000000..1d09a7e --- /dev/null +++ b/doc/Near plane clip/Clip algorithm.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + behind (z ≤ near) + in front (z > near) + + + + near plane + + + + + + + + + + + + + + v0 + + v1 + + v2 + + + + p′ + + p″ + + + t = 0.5 along v0 → v2 + + + + + t = (near − z1) / (z2 − z1) + p = p1 + t·(p2 − p1) + uv = uv1 + t·(uv2 − uv1) + + every edge crossing the plane spawns an interpolated vertex; + in-front vertices pass through unchanged + diff --git a/doc/Near plane clip/Fan triangulation.svg b/doc/Near plane clip/Fan triangulation.svg new file mode 100644 index 0000000..94a8656 --- /dev/null +++ b/doc/Near plane clip/Fan triangulation.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + v0 + + v1 + + p″ + + p′ + + + T1 = (v0, v1, p″) + T2 = (v0, p″, p′) + + + a triangle cut once + becomes a quad; + the rasterizer paints it + as a 2-triangle fan + sharing v0 + diff --git a/doc/Near plane clip/Near plane straddle.svg b/doc/Near plane clip/Near plane straddle.svg new file mode 100644 index 0000000..aa2c9a6 --- /dev/null +++ b/doc/Near plane clip/Near plane straddle.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + z (depth) → + x ↓ + + + + + camera + + + + + near plane z = 1 + + + + + + + floor tiles + + + + + + kept fragment + cut away + + + old: one vertex behind ⇒ + whole tile dropped + + + + new: clip at the plane, + paint the surviving fragment + + diff --git a/doc/Near plane clip/index.org b/doc/Near plane clip/index.org new file mode 100644 index 0000000..0c59a70 --- /dev/null +++ b/doc/Near plane clip/index.org @@ -0,0 +1,151 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Near-Plane Clipping - Aukio 3D +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \usepackage[none]{hyphenat} + +#+OPTIONS: H:20 num:20 +#+OPTIONS: author:nil + +#+HTML_HEAD: + +[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]] + +* The problem +:PROPERTIES: +:CUSTOM_ID: the-problem +:END: + +When the camera brushes against geometry — a floor tile under your +feet, a wall you lean into — part of a polygon can end up *behind* the +viewer while the rest stays in front. Perspective projection divides by +depth (=screenX = x / z=), so a vertex at z ≤ 0 has no meaningful screen +position at all. + +The naive way out — dropping any polygon that has even one vertex +behind the camera — makes whole tiles vanish exactly when they are +closest and largest on screen. Walking through the House demo, floor +tiles blinked out of existence at the bottom of the frame: + +#+attr_html: :class responsive-img +#+attr_latex: :width 640px +[[file:near-clip-before.png]] + +*Aukio 3D* instead *clips the polygon against the near plane* and +renders the surviving fragment. The same frame with clipping enabled — +the floor is solid to the bottom edge: + +#+attr_html: :class responsive-img +#+attr_latex: :width 640px +[[file:near-clip-after.png]] + +Think of the camera plane as the edge of a table and the polygon as a +sheet of paper partly hanging off it. Dropping the polygon means +throwing away the whole sheet. Clipping takes scissors, cuts the sheet +along the table edge, and keeps the part that lies on the table. + +#+INCLUDE: "Near plane straddle.svg" export html + +* Why not just clamp z? +:PROPERTIES: +:CUSTOM_ID: why-not-clamp-z +:END: + +A tempting one-liner is to force every vertex to =z = max(z, epsilon)= +and project anyway. It fails geometrically: a vertex at z = −50 clamped +to z = 0.01 projects to a screen coordinate thousands of pixels away, +*in the wrong direction* — the sign flip of the division mirrors it +through the camera. The polygon smears into giant streaks across the +frame instead of ending cleanly at the screen edge. + +Clipping produces the geometrically correct cut: the polygon's new edge +lies exactly on the near plane, and everything the rasterizer receives +has z > 0. + +* How the clipping works +:PROPERTIES: +:CUSTOM_ID: how-it-works +:END: + +Clipping happens in *camera space*, after the transform stack has moved +vertices relative to the viewer but *before* the perspective divide. +The vertex loop is walked edge by edge (Sutherland-Hodgman style) +against the plane =z = nearPlaneDistance=: + +1. An in-front vertex passes through unchanged. +2. An edge that crosses the plane spawns a new vertex at the + intersection, with position, UV and normal all interpolated with the + same parameter =t=. +3. A behind-plane vertex is skipped. + +#+INCLUDE: "Clip algorithm.svg" export html + +Interpolating UVs linearly along the 3D edge is exactly right for the +perspective-correct texture mapper: the intersection vertex is a real +point on the original edge, so its texture coordinate is the same blend +of the endpoints' UVs. Textured fragments therefore show the correct +texels right up to the cut, with no seam. + +Only a polygon with *all* vertices behind the plane is culled — the +legitimate version of the old behavior. + +* From clipped loop to pixels +:PROPERTIES: +:CUSTOM_ID: from-clip-to-pixels +:END: + +A convex N-gon crossing the plane clips to a single contiguous loop of +at most N+1 vertices. For the triangle-based rasterizers this means a +triangle can become a *quad*, which is painted as a two-triangle fan +sharing the first vertex — exact, because the clip of a convex polygon +stays convex: + +#+INCLUDE: "Fan triangulation.svg" export html + +Shape support: + +| Shape | Behavior when straddling | +|--------------------+---------------------------------------------------| +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] | Clipped loop painted as triangle fan | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]] | Fan-painted with interpolated UVs (also inherited by lightmapped GI fragments) | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.html][Line]] | Shortened to the in-front endpoint + intersection | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/Billboard.html][Billboard]] | Single anchor point: culled when behind, as before | + +Implementation notes: + +- Clipped output is stored *per pipeline slot* on the shape + (=clippedVertices(ctx)=), so the triple-buffered pipeline can + transform frame N+1 while frame N is still painting. +- Depth sorting and tile binning use the clipped vertices' average Z + and screen bounds — a clipped tile sorts as the fragment it became, + not as the polygon that reached behind you. +- New intersection vertices exist only in camera space; they are + projected directly via + [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/math/Vertex.html#setCameraSpaceCoordinate(double,double,double,eu.svjatoslav.aukio.e3d.gui.RenderingContext)][Vertex.setCameraSpaceCoordinate()]], + bypassing the transform stack. + +* Configuration +:PROPERTIES: +:CUSTOM_ID: configuration +:END: + +The near plane distance is a per-context knob, in world units: + +#+BEGIN_SRC java +// Default is 1.0; smaller values let the camera press closer to +// geometry before the scissors bite, at the cost of larger projected +// coordinates for clipped fragments. +viewPanel.getRenderingContext().nearPlaneDistance = 0.5; +#+END_SRC + +* Related Classes +:PROPERTIES: +:CUSTOM_ID: related-classes +:END: + +| Class | Purpose | +|---------------------------+----------------------------------------------------------------| +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.html][AbstractCoordinateShape]] | Vertex-loop clipping in =transform()=, per-slot clip storage | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/math/Vertex.html][Vertex]] | Camera-space projection for generated intersection vertices | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/RenderingContext.html][RenderingContext]] | Carries =nearPlaneDistance= | diff --git a/doc/Near plane clip/near-clip-after.png b/doc/Near plane clip/near-clip-after.png new file mode 100644 index 0000000..f135bd2 Binary files /dev/null and b/doc/Near plane clip/near-clip-after.png differ diff --git a/doc/Near plane clip/near-clip-before.png b/doc/Near plane clip/near-clip-before.png new file mode 100644 index 0000000..6b27c44 Binary files /dev/null and b/doc/Near plane clip/near-clip-before.png differ diff --git a/doc/Normal vector.svg b/doc/Normal vector.svg new file mode 100644 index 0000000..016136e --- /dev/null +++ b/doc/Normal vector.svg @@ -0,0 +1,18 @@ + + + + + + + + + N̂ + unit normal + (perpendicular + to surface) + + + Light + + L · N = brightness + diff --git a/doc/Perspective correct textures/Adaptive interval.svg b/doc/Perspective correct textures/Adaptive interval.svg new file mode 100644 index 0000000..924bc5c --- /dev/null +++ b/doc/Perspective correct textures/Adaptive interval.svg @@ -0,0 +1,59 @@ + + + + + + perspective curvature → rises toward the far end + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 16 px + 8 px + 4 px + 2 px + + one scanline, 100 px → + grazing-angle floor, far side + diff --git a/doc/Perspective correct textures/Affine distortion.png b/doc/Perspective correct textures/Affine distortion.png new file mode 100644 index 0000000..8d3722b Binary files /dev/null and b/doc/Perspective correct textures/Affine distortion.png differ diff --git a/doc/Perspective correct textures/Scanline correction.svg b/doc/Perspective correct textures/Scanline correction.svg new file mode 100644 index 0000000..cc5fc8d --- /dev/null +++ b/doc/Perspective correct textures/Scanline correction.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + screen pixel → + texel u ↑ + + + + + + + + + + + + + + + + + + + + exact perspective + + corrected every 16 px + + plain affine + diff --git a/doc/Perspective correct textures/index.org b/doc/Perspective correct textures/index.org new file mode 100644 index 0000000..3438f01 --- /dev/null +++ b/doc/Perspective correct textures/index.org @@ -0,0 +1,177 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Perspective-Correct Textures - Aukio 3D +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \usepackage[none]{hyphenat} + +#+OPTIONS: H:20 num:20 +#+OPTIONS: author:nil + +#+HTML_HEAD: + +[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]] + +* The problem +:PROPERTIES: +:CUSTOM_ID: introduction +:ID: a2b3c4d5-e6f7-8901-bcde-f23456789012 +:END: + +When a textured polygon is rendered at an angle to the viewer, naive +linear interpolation of texture coordinates produces visible +distortion. + +Consider a large textured floor extending toward the horizon. Without +perspective correction, the texture appears to "swim" or distort +because the texture coordinates are interpolated linearly across +screen space, not accounting for depth. + +#+attr_html: :class responsive-img +#+attr_latex: :width 1000px +[[file:Affine distortion.png]] + +The *Aukio 3D* engine solves this with *subdivided perspective +correction* inside the scanline rasterizer — the same technique Quake +used. + +* How perspective correction works +:PROPERTIES: +:CUSTOM_ID: how-perspective-correction-works +:END: + +Texture coordinates (u, v) are not linear in screen space, so they +cannot simply be stepped per pixel. But divide them by depth and they +become linear: *(u/z, v/z, 1/z) all interpolate linearly* across the +triangle in screen space. + +The rasterizer exploits this: + +1. Compute (u/z, v/z, 1/z) at each vertex +2. Interpolate all three across the scanline with plain additions +3. Every N pixels, recover the exact texture coordinate with one + division: =u = (u/z) / (1/z)= +4. Between correction points, step u/v affinely toward the next exact + point + +#+INCLUDE: "Scanline correction.svg" export html + +The orange polyline hugs the exact green curve: within each 16-pixel +block it is a straight line, but every block starts exactly on the +curve. The dashed pink line is plain affine interpolation — visibly +wrong everywhere except the endpoints. + +Think of it like walking with a map that is slightly distorted: you +walk in a straight line, but every 16 steps you check a landmark and +correct your course. The correction (a division) costs something, so +you do it every N pixels instead of every pixel — the divide cost is +amortized to 1/16th of a per-pixel-correct rasterizer. + +#+BEGIN_SRC java +// Per scanline (simplified from drawHorizontalLinePerspective): +while (done < span) { + // Advance (u/z, v/z, 1/z) to the end of this block + su += dsu * block; sv += dsv * block; sw += dsw * block; + double txNext = su / sw; // one reciprocal = exact texture position + double tyNext = sv / sw; + + // Step affinely through the block + double txStep = (txNext - tx) / block; + double tyStep = (tyNext - ty) / block; + for (int i = 0; i < block; i++) { + plot(x++, texture.sample(tx, ty)); + tx += txStep; ty += tyStep; + } + done += block; +} +#+END_SRC + +** Adaptive correction interval + +Quake used a fixed 16-pixel interval. This engine keeps 16 as the +default but *shrinks the interval when the error bound demands it*. + +The error of affine stepping within a block grows with both the +texture gradient (texels per pixel) and the perspective curvature +(how fast 1/z changes across the span). Each scanline computes + +#+BEGIN_EXAMPLE +error(texels) ≈ texelRate · interval² · k / 2 +#+END_EXAMPLE + +where =k = |d(1/z)| / min(1/z)= is the per-pixel relative depth change, +and picks the largest power-of-two interval from the ladder 16, 8, 4, +2, 1 that keeps the bound under half a texel. Flat, gently angled +spans keep the fast 16-pixel cadence; a floor tile seen at a grazing +angle drops to shorter intervals exactly where the curvature is high. + +#+INCLUDE: "Adaptive interval.svg" export html + +** When affine is good enough + +For small or nearly flat triangles, plain affine mapping is already +within half a texel of exact perspective, so the perspective setup is +skipped entirely. The test compares the texture range the triangle +covers against its depth variation: + +#+BEGIN_EXAMPLE +affine is sufficient when texelSpan · (zMax/zMin − 1) < 2 +#+END_EXAMPLE + +Note the criterion is the *texel* span, not the pixel size — a tiny +on-screen triangle can still map many texels into few pixels. Distant +clusters of small triangles (a common case) all render through the +cheaper affine path. + +Triangles straddling the near plane (any vertex closer than z = 0.001) +also fall back to affine, because 1/z interpolation is invalid there. + +** Toggling the correction + +Perspective correction can be switched off globally for A/B comparison +or debugging: + +#+BEGIN_SRC java +TexturedTriangle.setPerspectiveCorrectionEnabled(false); // plain affine everywhere +#+END_SRC + +With correction disabled, large triangles at steep angles visibly warp +— useful for demonstrating what the correction actually buys. + +* Mipmap selection +:PROPERTIES: +:CUSTOM_ID: mipmap-selection +:END: + +Perspective correction fixes *where* a texel is sampled; mipmapping +decides *which resolution* to sample from. Each triangle estimates its +screen-pixels-per-texel ratio from edge lengths: + +#+BEGIN_EXAMPLE +scaleFactor = (sum of screen edge lengths) / (sum of UV edge lengths) · 1.2 +#+END_EXAMPLE + +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/texture/Texture.html#getMipmapForScale(double)][Texture.getMipmapForScale()]] +then picks the lazily-generated mipmap level closest to that scale: +halved resolutions when the texture is minified, doubled when strongly +magnified. Sampling a smaller mipmap under minification both speeds up +rendering (better cache behavior) and reduces aliasing. + +* Related Classes +:PROPERTIES: +:CUSTOM_ID: related-classes +:END: + +| Class | Purpose | +|-------------------------------+--------------------------------------------------------------------| +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]] | Textured triangle with perspective-correct and SDF rendering paths | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PerspectiveBorderInterpolator.html][PerspectiveBorderInterpolator]] | Edge walker interpolating (u/z, v/z, 1/z) along triangle borders | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PolygonBorderInterpolator.html][PolygonBorderInterpolator]] | Edge walker for plain affine mapping | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/texture/Texture.html][Texture]] | Mipmap container; also carries the SDF mask and color layers | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureBitmap.html][TextureBitmap]] | Raw pixel array for one mipmap level | + +*See also:* + +- [[file:../SDF textures/][SDF textures]] — signed-distance-field glyph rendering, the + alternative sampling path in =TexturedTriangle= that reuses the same + perspective-correct interpolation for crisp text at any angle. diff --git a/doc/Point3D vertex.svg b/doc/Point3D vertex.svg new file mode 100644 index 0000000..0954bac --- /dev/null +++ b/doc/Point3D vertex.svg @@ -0,0 +1,105 @@ + + + + + + + +Point3D +raw coordinates + + + + + + +(x, y, z) + + + + + +.getDistanceTo() + + + + +.rotate() + + + + +.add() +.subtract() + + + + +.crossProduct() + + + + +.unit() +.dot() + + + + +.multiply() + + +Mutable, fluent API +Positions, vectors, math + + +Vertex +rendering-ready wrapper + + + + + + + +coordinate +Point3D + + + +wraps + + + +transformedCoordinate + + + +onScreenCoordinate + + + +textureCoordinate +UV + + + +normal +for CSG + + +local +camera +space +2D +pixels + + + +local +screen + + + +Tracks position across coordinate spaces + diff --git a/doc/Rendering loop/CPU scheduling.png b/doc/Rendering loop/CPU scheduling.png new file mode 100644 index 0000000..12aa23d Binary files /dev/null and b/doc/Rendering loop/CPU scheduling.png differ diff --git a/doc/Rendering loop/Double buffering.svg b/doc/Rendering loop/Double buffering.svg new file mode 100644 index 0000000..141dad6 --- /dev/null +++ b/doc/Rendering loop/Double buffering.svg @@ -0,0 +1,47 @@ + + + + + Without double-buffering + + + display shows partial update + + + + old frame + + + + ← tear + + + + new frame + + + With double-buffering + + + + Back buffer + (draw here) + + + + + + + + + swap + + + + Front buffer + (displayed) + + + complete + frame + diff --git a/doc/Rendering loop/Paint tiles.svg b/doc/Rendering loop/Paint tiles.svg new file mode 100644 index 0000000..e27d5f8 --- /dev/null +++ b/doc/Rendering loop/Paint tiles.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + one shape + + ~10 tiles per thread; threads steal pending + tiles — no fixed thread↔tile assignment + diff --git a/doc/Rendering loop/Painter's algorithm.svg b/doc/Rendering loop/Painter's algorithm.svg new file mode 100644 index 0000000..7727fc2 --- /dev/null +++ b/doc/Rendering loop/Painter's algorithm.svg @@ -0,0 +1,13 @@ + + + + + + Far (Z=500) — painted first + + + Medium (Z=300) — painted second + + + Near (Z=100) — painted last + diff --git a/doc/Rendering loop/Render pipeline.svg b/doc/Rendering loop/Render pipeline.svg new file mode 100644 index 0000000..927e357 --- /dev/null +++ b/doc/Rendering loop/Render pipeline.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + Shapes + + + Transform + + + Sort + + + Bin + + + Paint + + + Present + + + Screen + + + + + + + + + + + 3D vertices + world→screen + back-to-front + per tile + tile grid + own thread + diff --git a/doc/Rendering loop/index.org b/doc/Rendering loop/index.org new file mode 100644 index 0000000..c7fe2e0 --- /dev/null +++ b/doc/Rendering loop/index.org @@ -0,0 +1,523 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Rendering Loop - Aukio 3D +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \usepackage[none]{hyphenat} + +#+OPTIONS: H:20 num:20 +#+OPTIONS: author:nil + +#+HTML_HEAD: + +[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]] + +* Rendering loop +:PROPERTIES: +:CUSTOM_ID: rendering-loop +:ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890 +:END: + +The rendering loop is the heart of the engine, continuously generating +frames on a dedicated background thread. It orchestrates the entire +rendering pipeline from 3D world space to pixels on screen. + +** What is a render loop? +:PROPERTIES: +:CUSTOM_ID: what-is-a-render-loop +:END: + +A *render loop* is a continuous process that generates visual frames +from 3D data. Think of it like a movie camera: each "frame" captures +the current state of the 3D world and converts it into a 2D image that +can be displayed on screen. + +The process transforms shapes through multiple coordinate systems: + +#+INCLUDE: "Render pipeline.svg" export html + +Each step has a specific purpose: + +| Step | Input | Output | Purpose | +|-----------+-------+--------+---------| +| Shapes | 3D [[file:../index.org::#vertex][vertices]], [[file:../index.org::#mesh][meshes]] | Scene data | Objects waiting to be drawn | +| Transform | World coordinates | Screen coordinates | Convert 3D positions to where they appear on screen (see [[file:../index.org::#coordinate-system][coordinate system]]); cull shapes outside the view frustum. Parallel: heavy subtrees fork onto the worker pool | +| Sort | Unordered shapes | Ordered by depth | Ensure correct visibility (far objects painted first). Parallel merge sort for large scenes | +| Bin | Sorted shapes | Per-tile shape lists | Each paint tile iterates only shapes that can touch it. Parallel over the worker pool | +| Paint | Per-tile shape lists | Pixels in buffer | Tiles split the screen into independent work units so clearing and rasterization run in parallel across CPU cores | +| Present | Pixel buffer | Screen image | Hand the completed frame to a dedicated thread that copies it to the display | + +This pipeline runs repeatedly, targeting 60 frames per second by +default. Even if nothing moves, the loop continues running—but the +engine [[#frame-listeners][skips unnecessary work]] when the scene is static. + +The steps above describe one frame *logically*, in the order data flows +through it. In execution the engine is a software pipeline: transform +of the next frame already runs while the previous frame is still being +painted, and presentation happens on its own thread. See +[[#software-pipeline][Software pipeline]]. + +** Main loop structure +:PROPERTIES: +:CUSTOM_ID: main-loop-structure +:END: + +The engine runs two dedicated daemon threads: + +- =e3d-render= — produces frames. It runs continuously: + +#+BEGIN_SRC java +while (renderThreadRunning) { + ensureThatViewIsUpToDate(); // Produce one frame (or skip) + maintainTargetFps(); // Sleep if ahead of schedule +} +#+END_SRC + +- =e3d-present= — presents frames. It takes completed frames from a + mailbox and performs all display-path work (the multi-megabyte + =drawImage=, =BufferStrategy.show()= and the X server round-trip), so + the render thread never blocks on the display. + +Both threads are daemons, so they stop automatically when the JVM +exits. You can stop them explicitly with [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/ViewPanel.html#stop()][ViewPanel.stop()]]. + +** Frame rate control +:PROPERTIES: +:CUSTOM_ID: frame-rate-control +:END: + +The engine supports two modes: + +- *Target FPS mode*: Set with [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/ViewPanel.html#setFrameRate(int)][setFrameRate(int)]]. + The engine tries to maintain the target rate by sleeping between frames. + + - *When rendering is slower than target*: No sleeping occurs. The engine + runs at maximum hardware speed. Missed frames are skipped, not + rendered later — the timing simply resets to current time. + + - *When rendering is faster than target*: The thread sleeps to limit FPS + to the target rate, avoiding unnecessary CPU usage. + + For example, with a 60 FPS target: + - If a complex scene takes 30ms per frame, you get ~33 FPS (hardware limit) + - If the scene later simplifies to 10ms per frame, you get exactly + 60 FPS (throttled by sleeping) + +- *Unlimited mode*: Set =setFrameRate(0)= or negative. No sleeping — + renders as fast as possible, and frames are produced even when the + scene reports no changes, so the measured rate reflects maximum + achievable throughput. Useful for benchmarking. + +*Production vs presentation.* These are measured separately: + +- *Production rate* (=getMeasuredFPS()=) counts frames the pipeline + completes per second. This is the benchmark number. +- *Presentation rate* is how fast frames actually reach the screen. In + capped-FPS mode the present thread is paced to 60 blits per second + (override with =-Daukio3d.presentRate=N=); the cap exists because the + X server also dispatches input, and flooding it with blits causes + desktop-wide mouse/keyboard jitter. When production outruns + presentation, stale frames are dropped from the mailbox instead of + piling up latency. In unlimited (benchmark) mode presentation pacing + is disabled entirely, so it cannot throttle production. + +* Software pipeline +:PROPERTIES: +:CUSTOM_ID: software-pipeline +:END: + +The phases below are described per frame, but consecutive frames +*overlap*. The engine triple-buffers everything a frame writes: + +- 3 framebuffers (each with its own =RenderingContext=) +- 3 projection buffer slots (per-vertex screen state) +- 3 render aggregators (transform output, sort/bin state) + +A render pass P (one per frame, or one per eye in stereo) transforms +into slot P mod 3, so it only conflicts with the paint of pass P-3. +Before each transform the render thread *flushes* completed paint +passes (mouse hits, frame deposit) and blocks only if paint P-3 is +still running — which steady-state worker throughput prevents. Workers +finishing one pass's tiles flow straight into the next pass's queued +tiles with no idle gap. + +Completed frames go to a *presentation mailbox* that keeps only the +newest frame: if the display path is slower than production, stale +frames are dropped (and their buffers released) instead of +accumulating latency — swapchain "mailbox mode". + +A per-buffer *present gate* guarantees painting frame F+3 never +overwrites a buffer the present thread is still blitting frame F from. + +The goal of all this overlap is throughput: keep every CPU core busy, +all the time. No phase waits for another phase of the same frame when +it could already be working on the next one. The Developer Tools +thread-activity timeline shows it working — all 18 worker rows packed +solid with paint, bin and sort tasks from up to three frames at once, +while the render thread (top row) and present thread tick along above +them: + +#+attr_html: :class responsive-img +[[file:CPU scheduling.png]] + +The pipeline can be disabled with =-Daukio3d.pipeline=false=, restoring +strictly sequential phase order (each paint pass is awaited +immediately). This is a kill switch for benchmarking and regression +hunting. + +* Rendering phases +:PROPERTIES: +:CUSTOM_ID: rendering-phases +:END: + +Each frame goes through 6 phases. Phases 2–4 run inside an +asynchronous continuation on the shared worker pool, and phases of +consecutive frames overlap as described in [[#software-pipeline][Software pipeline]]. + +** Phase 1: Transform shapes +:PROPERTIES: +:CUSTOM_ID: phase-1-transform-shapes +:END: + +All shapes are transformed from world space to screen space: + +1. Build camera-relative transform (inverse of camera position/rotation) +2. Update the view frustum from camera state and viewport dimensions +3. Walk the scene tree: + - Cull composite shapes whose bounding box misses the frustum + - Apply camera transform + - Project 3D → 2D (perspective projection) + - Calculate depth for sorting + - Queue for rendering + +*What is coordinate transformation?* + +Every shape exists in "world space" — its own position in the 3D world. +To render it, we must convert to "screen space" — where it appears on +your monitor. This involves: + +- *Translation*: Move coordinates relative to camera position +- *Rotation*: Rotate coordinates based on camera orientation +- *Projection*: Convert 3D (x, y, z) to 2D (x, y) screen pixels + +Objects further away appear smaller (perspective). The [[file:../index.org::#coordinate-system][coordinate system]] +uses Y-down to match screen conventions, making projection straightforward. + +The transform is *parallel and non-blocking*: composites with enough +children fork their render lists into chunk tasks on the shared worker +pool (at any nesting level), and the render thread returns without +waiting. The chunk tasks are drained and merged on a worker thread +inside the paint continuation, while the render thread is already +walking the next pass. + +*Frustum culling* happens here: composites test their bounding box +against the frustum and skip invisible subtrees entirely, saving both +transform and paint work. Per-frame culling statistics are collected +for the developer tools panel. + +** Phase 2: Sort shapes by depth +:PROPERTIES: +:CUSTOM_ID: phase-2-sort-shapes +:END: + +Shapes are sorted by depth in descending order (farthest first), with +the shape id as a deterministic tiebreaker: + +#+BEGIN_SRC java +// ShapesZIndexComparator: descending Z, ties broken by shape id +if (z1 < z2) return 1; // z1 is nearer -> sort after z2 +else if (z1 > z2) return -1; // z1 is farther -> sort before z2 +return Integer.compare(o1.shapeId, o2.shapeId); +#+END_SRC + +Above 8192 queued shapes the sort runs as an instrumented parallel +merge sort on the shared worker pool; below that it is single-threaded. + +*Why sort back-to-front?* + +This implements the *painter's algorithm* — like painting a landscape: +first paint the sky (farthest), then mountains, then trees, then the +foreground. Each layer covers what's behind it. + +#+INCLUDE: "Painter's algorithm.svg" export html + +Without sorting, nearby objects might be painted first and then covered +by distant ones, causing visual errors. This is especially important for +*transparent objects* — you need to see through the near ones to what's +behind. + +The Z value represents distance from the camera after transformation. +Larger values = further away. The id tiebreaker keeps the order +deterministic frame-to-frame, which tiled rendering relies on: every +tile paints its shapes in the same global (Z, id) order. + +** Phase 3: Bin shapes into tiles +:PROPERTIES: +:CUSTOM_ID: phase-3-bin-shapes-into-tiles +:END: + +The sorted queue is binned per paint tile by screen-space overlap: +each tile's bin lists only the shapes whose vertex bounds (plus a +paint margin) can touch that tile. A shape overlapping several tiles +is added to each of their bins. + +This means a paint thread iterates a short local list instead of the +whole scene, and it is what makes the tile grid scale: refining the +grid shrinks each bin instead of just subdividing the clearing work. + +Binning is parallelized over the shared worker pool. + +** Phase 4: Clear and paint tiles (multi-threaded) +:PROPERTIES: +:CUSTOM_ID: phase-4-clear-paint-tiles +:END: + +The viewport is divided into a grid of rectangular *tiles* — roughly +10 tiles per render thread, split into near-squares (square tiles +minimize boundary crossings, i.e. how many tiles each shape overlaps). +These are *not* horizontal bands: each tile has both X and Y bounds. + +#+INCLUDE: "Paint tiles.svg" export html + +Painting is work-stolen, not pre-assigned. All tile tasks go onto a +shared =ForkJoinPool= (sized to 75% of CPU threads by default, at most +cores − 1, so one thread stays free for the rest of the system; +changeable at runtime via =setNumRenderThreads(int)=). A worker that +finishes a cheap tile immediately pulls the next queued task — another +tile (of this or an adjacent frame's pass), a transform chunk, a sort +piece — so cores never idle behind a busy thread. + +Each tile task: + +1. *Clear tile*: fill its rectangle with background color +2. *Paint shapes*: rasterize the tile's bin, back-to-front, clipping + at tile bounds + +Both operations happen within the same task, so clearing always +completes before painting on that tile. Parallel clearing across +disjoint tiles maximizes memory bandwidth utilization. + +Each tile renders through a =SegmentRenderingContext= — a view of the +frame context carrying the tile's X/Y bounds and a =Graphics2D= +pre-clipped to the tile rectangle for thread-safe text and +anti-aliased drawing. (The class name predates the tile grid; a +"segment" is now a tile.) Mouse hit detection happens during painting, +before clipping. + +A =CountDownLatch= tracks completion of all the pass's tiles — but the +render thread does *not* wait for it here. The latch is awaited one +pass later, during the flush (see [[#phase-5-flush-completed-passes][Phase 5]]). + +** Phase 5: Flush completed passes +:PROPERTIES: +:CUSTOM_ID: phase-5-flush-completed-passes +:END: + +Before each new transform, the render thread flushes paint passes that +have completed. For each flushed pass: + +1. Await its tile latch (blocks only when correctness demands it — + transform of pass P may not start before paint of pass P-3 finished) +2. *Combine mouse results*: during painting, each tile tracked which + shape is under the mouse cursor. Since all tiles paint the same + back-to-front order, they should all report the same hit; the first + non-null result wins: + +#+BEGIN_SRC java +for (SegmentRenderingContext ctx : segmentContexts) { + if (ctx.getSegmentMouseHit() != null) { + context.setCurrentObjectUnderMouseCursor(ctx.getSegmentMouseHit()); + return; + } +} +#+END_SRC + + In stereo mode this only runs for the eye whose viewport actually + contains the cursor — each eye sees a different camera position, so + combining for the wrong eye would overwrite a valid hit with null. +3. If this pass completed a frame, deposit the frame into the + presentation mailbox (see [[#software-pipeline][Software pipeline]]). The render + thread never blocks on the display. + +Passes that finished painting are flushed without any blocking, so +completed frames reach the mailbox as early as possible. + +** Phase 6: Present frame +:PROPERTIES: +:CUSTOM_ID: phase-6-present-frame +:END: + +The =e3d-present= thread takes the newest mailbox frame (dropping any +unshown older frame) and copies its =BufferedImage= to the screen using +[[https://cr.openjdk.org/~iris/se/17/latestSpec/api/java.desktop/java/awt/image/BufferStrategy.html][BufferStrategy]] for tear-free page-flipping: + +#+BEGIN_SRC java +do { + Graphics2D g = bufferStrategy.getDrawGraphics(); + g.drawImage(context.bufferedImage, 0, 0, null); + g.dispose(); +} while (bufferStrategy.contentsRestored()); + +// framebuffer released for reuse here +bufferStrategy.show(); +Toolkit.getDefaultToolkit().sync(); +#+END_SRC + +The frame's buffer is released for reuse right after the =drawImage= +loop — =show()= and =sync()= touch only the BufferStrategy's own back +buffer and the X connection, and at high resolutions they cost more +than the draw itself, so the next frame's painters don't wait for them. + +*What is double-buffering?* + +Without double-buffering, the screen updates while pixels are being +written. This causes *screen tearing* — visible horizontal splits where +the top of the frame shows old content while the bottom shows new. + +#+INCLUDE: "Double buffering.svg" export html + +Double-buffering uses two pixel buffers: +- *Back buffer*: Where rendering happens (offscreen, invisible) +- *Front buffer*: What's currently displayed on screen + +When rendering completes, the buffers *swap* in one atomic operation. +The viewer always sees complete frames, never partial updates. + +The =do-while= loop handles the case where the OS recreates the back +buffer (common during window resizing). Since our offscreen +=BufferedImage= still has the correct pixels, we only need to re-blit, +not re-render. + +* Frame listeners and smart repaint skipping +:PROPERTIES: +:CUSTOM_ID: frame-listeners +:ID: e360a877-cca6-4cba-a9a4-ea40b0f1a183 +:END: + +A *FrameListener* is a callback that runs custom logic before each potential +frame. Think of it as your "per-frame hook" — the engine calls all registered +listeners, giving them a chance to update animations, physics, or game logic. + +** Registering a frame listener + +Use [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/ViewPanel.html#addFrameListener(eu.svjatoslav.aukio.e3d.gui.FrameListener)][addFrameListener()]] to register your callback: + +#+BEGIN_SRC java +// This is how you register a frame listener +viewPanel.addFrameListener((panel, deltaMs) -> { + // Example: simple animation listener + double rotationSpeed = 1.0; // radians per second + shape.rotate(rotationSpeed * deltaMs / 1000.0); // Framerate-independent rotation + return true; // Request repaint (shape moved) +}); +#+END_SRC + +The listener receives two parameters: +- =panel=: The ViewPanel that's rendering +- =deltaMs=: Milliseconds since last frame (for framerate-independent animation) + +The return value controls whether the frame gets rendered: +- =true=: "Something changed — repaint the screen" +- =false=: "Nothing changed — can skip this frame" + +** Frame skipping optimization + +The engine avoids unnecessary rendering. A frame is skipped when: +- *All listeners return false* (nothing changed in your scene) +- *Camera did not move* (built-in Camera listener returns false once + the camera comes to rest) +- *No resize or repaint requests* + +This means a static scene with no animations consumes almost zero CPU. +The render thread keeps running (checking for changes), but actual pixel +rendering is skipped entirely. Skipped frames still flush any pending +paint passes from earlier frames, so in-flight frames always reach the +screen. + +Two exceptions force a frame regardless of listeners: +- *Unlimited (benchmark) mode* (=targetFPS <= 0=) renders continuously, + so the measured rate reflects maximum throughput +- An explicit repaint request (resize, stereo toggle, + =repaintDuringNextViewUpdate()=, etc.) + +#+BEGIN_SRC java +// Example: listener that only requests repaint when needed +viewPanel.addFrameListener((panel, deltaMs) -> { + if (gameState.hasUpdates()) { + gameState.processUpdates(); + return true; // Only repaint when game state actually changed + } + return false; // Skip frame — nothing to update +}); +#+END_SRC + +** Built-in listeners + +The engine registers these listeners by default: +- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/Camera.html][Camera]] — applies movement velocity and friction each frame, and + returns true when the camera actually moved (more than a small + threshold), i.e. while the user is actively navigating +- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/humaninput/InputManager.html][InputManager]] — processes mouse/keyboard events + +When the camera stops moving and you release all keys, the Camera listener +returns false. If your custom listeners also return false, the frame is +skipped until something changes. + +* Rendering context +:PROPERTIES: +:CUSTOM_ID: rendering-context +:END: + +The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/RenderingContext.html][RenderingContext]] holds all state for rendering into one framebuffer: +the pixel buffer, projection parameters, and per-frame bookkeeping. + +| Field | Purpose | +|-------+---------| +| =pixels[]= | Raw pixel buffer (int[] in RGB format) | +| =bufferedImage= | Java2D wrapper around pixels | +| =graphics= | Graphics2D for text, lines, shapes | +| =width=, =height= | Full framebuffer dimensions | +| =centerCoordinate= | Screen center of the active viewport (for projection) | +| =projectionScale= | Perspective scale factor, derived from viewport width. Mutable: each stereo eye sets its own | +| =renderMinX=, =renderMaxX= | X bounds of the active viewport or tile | +| =renderMinY=, =renderMaxY= | Y bounds (full height on the frame context, tile bounds on segment views) | +| =stereoEye=, =stereoViewportWidth=, =stereoViewportOffsetX= | Which eye this pass renders and where its viewport sits in the buffer | +| =tilesX=, =tilesY=, =viewportCount=, =numRenderSegments= | Tile grid geometry (segments = tilesX × tilesY × viewports) | +| =frustum= | View frustum for culling, rebuilt each pass from camera state | +| =frameNumber= | Per-context frame counter | +| =transformCycleId= | Globally unique transform-cycle id, safe key for per-cycle memoization | +| =vertexSlot= | Projection buffer slot (0–2) this pass transforms into | + +** Triple-buffered frame contexts + +The engine keeps /three/ frame contexts, cycled by frame parity. While +frame N is still being painted from one buffer, frame N+1 already +transforms into the next — paint threads never idle waiting for the +transform phase, and vice versa. A per-buffer *present gate* prevents +painting frame F+3 into a buffer the present thread is still blitting +frame F from. + +All three contexts are recreated together when the window is resized, +when the tile grid changes (render thread count), or when stereo mode +is toggled. Otherwise they are reused — =prepareForNewFrameRendering()= +just resets per-frame state like mouse tracking. + +** Per-pass copies + +Each render pass (one per eye in stereo) works on a private /copy/ of +the frame context. The copy shares the pixel buffer, graphics and +services, but owns the projection fields (center, scale, viewport, +vertex slot), so the next pass's setup cannot disturb a pass whose +transform or paint is still in flight. + +Consequence for engine code: per-frame mutable state must be allocated +eagerly on the frame context. Anything created lazily inside a pass +lands on the throwaway copy and is lost. + +** Tile segment views + +Each paint tile gets a [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/SegmentRenderingContext.html][SegmentRenderingContext]], +a view that shares the framebuffer with its parent but carries its own +X/Y tile bounds and a pre-clipped =Graphics2D= for thread-safe text and +shape drawing. Mouse hits are tracked per tile and combined after all +tiles finish painting. diff --git a/doc/SDF textures/SDF concept.svg b/doc/SDF textures/SDF concept.svg new file mode 100644 index 0000000..bc1b750 --- /dev/null +++ b/doc/SDF textures/SDF concept.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + Why a distance field, not a bitmap + one glyph edge, magnified 8x - stored coverage vs re-derived coverage + + + BITMAP coverage + what you store is what you get + + + + + + + + + + + + + + + + + + + + + + + + + + + + texel grid IS the resolution limit: + edges stair-step, curves become blocks + + + SDF: distance to edge + a smooth field - the edge is re-derived per pixel + + + + + + + + + + + + + + + + edge recovered at + display resolution + + + d < 0: inside ink + d > 0: outside + d = 0: the edge + + + + coverage = (127.5 - d) * aaK + 128 + aaK scales the gradient window to the pixel footprint - the same 16x32 texel field + serves a 4-pixel label and a full-screen billboard + diff --git a/doc/SDF textures/SDF glyph pipeline.svg b/doc/SDF textures/SDF glyph pipeline.svg new file mode 100644 index 0000000..09473d6 --- /dev/null +++ b/doc/SDF textures/SDF glyph pipeline.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + Glyph field generation (SdfGlyphCache) + once per character, then cached - stamping is a block copy + + + + 1. rasterize glyph + Liberation Mono Bold, AA on + 64x128 px (4x supersample) + font auto-sized to fit cell + advance 0.6em (Courier-compat) + + + + + + 2. distance transform + exact Euclidean EDT + (Felzenszwalb-Huttenlocher, + two separable 1-D passes) + dOut to ink, dIn to background + + + + + + 3. sign, clamp, average + signed = dOut - dIn + clamp to +/- 2 texels spread + average FIELD down to 16x32 + (averaging the field, not coverage, + preserves the edge position) + + + + + + + mask encoding (per texel, 0..255) + 0 = deep inside ink 127.5 = the edge 255 = far outside + + + + + + + + + + + + + + + + + TextCanvas.putChar + stamps the cached 16x32 mask + into the cell position of sdfMask + + + three texture layers + sdfMask: glyph SHAPES (bilinear) + sdfForeground + primary: colors + + why sans-serif bold: Courier's serifs and hairline strokes decay into unresolvable noise + when the distance field is minified - uniform sturdy strokes survive + + + + NO mipmaps on the mask: the edge gradient spans ~2 texels, + half-res masks melt glyph edges - minification is analytic instead + diff --git a/doc/SDF textures/SDF minification.svg b/doc/SDF textures/SDF minification.svg new file mode 100644 index 0000000..95cce51 --- /dev/null +++ b/doc/SDF textures/SDF minification.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + Minification: analytic coverage window + one screen pixel covering many texels still resolves the edge correctly + + + + a screen pixel on the texture + + + + + + + + + + + + + + + + + + + footprint: many texels per pixel + a bitmap would average to mush or alias; + the field still knows where the edge is + + + + per-axis footprint from UV gradients + footX = |dUV/dx|, footY = |dUV/dy| + window follows the SHARPEST axis + + + + + aaK = 2*spread / texelsPerPixel + widens the coverage window as pixels grow + + + + + perceptual corrections (minified text + else reads as gray haze): + SHARPEN x2: sub-pixel window kills halo + coverage gamma < 1: stem darkening + + + + result: graceful degradation + magnified: edges re-derived at display resolution - razor sharp + minified: coverage fades smoothly to clean gray, no crawling aliases + angled: the uncompressed axis keeps its sharpness + diff --git a/doc/SDF textures/glyph-sdf-S.png b/doc/SDF textures/glyph-sdf-S.png new file mode 100644 index 0000000..ecc1de5 Binary files /dev/null and b/doc/SDF textures/glyph-sdf-S.png differ diff --git a/doc/SDF textures/index.org b/doc/SDF textures/index.org new file mode 100644 index 0000000..ff4cf78 --- /dev/null +++ b/doc/SDF textures/index.org @@ -0,0 +1,252 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: SDF Textures - Aukio 3D +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} + +[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]] + +* What SDF textures are +:PROPERTIES: +:CUSTOM_ID: what-sdf-is +:END: + +A regular texture stores *coverage*: each texel says "this much ink +here". That is a photocopy of the glyph — resample it (magnify, minify, +view at an angle) and the stored pixels blur or alias, because the +information about /where the edge is/ was thrown away when the glyph +was rasterized. + +A *signed distance field* (SDF) texture stores something smarter: per +texel, the *distance to the nearest edge* — negative inside the ink, +positive outside, zero exactly on the boundary. The rasterizer then +re-derives coverage per screen pixel from this smooth field. The edge +position survives resampling because the field around it is linear — +bilinear interpolation of a linear ramp is exact. + +#+ATTR_HTML: :width 640 +[[file:SDF concept.svg]] + +In Aukio 3D the mask is a grayscale field in =texture.sdfMask=: + +- =0= — deep inside the ink +- =127.5= — exactly on the edge +- =255= — far outside any glyph + +The gradient spans only =SPREAD_TEXELS = 2.0= texels around the edge — +that narrow band is all the rasterizer needs. + +Here is a real field, dumped straight from =SdfGlyphCache= (glyph "S", +16x32 texels, upscaled 12x with nearest so you can see the texels): + +[[file:glyph-sdf-S.png]] + +Dark inside the strokes, bright outside, and a smooth gray ramp exactly +two texels wide around the contour. + +* Generating glyph fields +:PROPERTIES: +:CUSTOM_ID: glyph-pipeline +:END: + +=SdfGlyphCache= generates each character's distance field once and +caches it in a =ConcurrentHashMap=; stamping a glyph into a canvas is +then just a block copy. + +#+ATTR_HTML: :width 640 +[[file:SDF glyph pipeline.svg]] + +The steps: + +1. *Rasterize* the glyph with AWT at 4x the cell size (64x128 pixels) + with anti-aliasing on, using Liberation Mono Bold (metric-compatible + with Courier New, so the cell grid is unchanged). The font size is + auto-shrunk until the widest glyph fits the scratch without clipping + — a clipped glyph would corrupt the distance field at the cell edge. +2. *Distance transform*: an exact Euclidean distance transform + (Felzenszwalb & Huttenlocher, two separable 1-D passes over parabola + envelopes) is run twice — once for distance to nearest ink pixel, + once for distance to nearest background pixel. +3. *Sign, clamp, average*: signed distance = dOut - dIn, clamped to + +/-2 texels of spread, then the *field* (not coverage) is averaged + down to the 16x32 cell resolution. Averaging the field preserves the + edge position; averaging coverage would not. + +The font choice matters: Courier's serifs and hairline strokes decay +into unresolvable noise when the field is minified. A uniform-stroke +bold sans-serif survives. + +* The rendering path +:PROPERTIES: +:CUSTOM_ID: render-path +:END: + +When =texture.isSdf()= is true (an =sdfMask= is attached), +=TexturedTriangle.paintSdf= takes over. Three layers are involved: + +| Layer | Contents | Sampling | +|------------------+-----------------------------+----------| +| =sdfMask= | glyph shapes (the field) | bilinear | +| =sdfForeground= | ink color, flat per cell | nearest | +| =primaryBitmap= | background color, per cell | nearest | + +Per screen pixel: + +1. Sample the mask bilinearly (fixed-point) -> distance =d=. +2. Convert to coverage: =cov = (127.5 - d) * aaK + 128=, clamped to + [0, 256]. =aaK= scales the 2-texel gradient window to the current + pixel footprint (see next section). +3. Blend: =pixel = bg * (1 - cov) + fg * cov=. + +Perspective-correct interpolation applies to SDF triangles exactly as +it does to regular textured triangles — same affine-sufficiency test, +same subdivided correction. See +[[file:../Perspective correct textures/index.org][Perspective-correct +textures]]; only the per-pixel sampling differs. + +* Minification without mipmaps +:PROPERTIES: +:CUSTOM_ID: minification +:END: + +*There is deliberately no mipmap chain for SDF layers.* A distance +field's edge gradient spans ~2 texels; a half-resolution mask melts the +glyph edges. Worse, the two triangles of a rectangle cross mip +thresholds at slightly different distances, producing a hard diagonal +quality split and sudden blur steps while dollying (observed in +practice). + +Minification is instead handled *analytically*: the coverage window is +widened by the screen-space pixel footprint, giving area-correct +coverage straight from the primary field. + +#+ATTR_HTML: :width 640 +[[file:SDF minification.svg]] + +The footprint is computed per axis from the screen-space UV gradients — +=text on an angled plane is minified mostly along one axis=, and an +isotropic average would blur the axis that still has resolution to +spare. The coverage window follows the sharpest axis. + +Area-correct coverage alone reads as a low-contrast gray haze, so two +perceptual corrections (A/B-tuned on far + angled text) kick in under +minification: + +- *Sharpening* (=SDF_SHARPEN=, default 2): narrows the coverage window + below one pixel — kills the haze halo at the cost of slight shimmer. +- *Coverage gamma* (< 1, automatic from the footprint): darkens stems + like a small-size font rasterizer, keeping thin strokes present. + +Real output, rendered headlessly through the [[file:../index.org::#snapshot][Snapshot tool]]: + +Magnified — edges re-derived at display resolution, razor sharp: + +[[file:sdf-near.png]] + +At moderate distance: + +[[file:sdf-mid.png]] + +Far away — small but clean, fading to gray instead of disintegrating +into aliases (right: 4x nearest zoom of the center): + +[[file:sdf-far.png]] + +[[file:sdf-far-zoom.png]] + +At an oblique angle — foreshortened along one axis, still sharp along +the other: + +[[file:sdf-angled.png]] + +* Using it +:PROPERTIES: +:CUSTOM_ID: using-sdf +:END: + +*TextCanvas* is the main entry point: a textured rectangle carrying a +character grid in 3D space. World cell size 8x16 units, texture cell +16x32 texels (2 texels per world unit). + +#+BEGIN_SRC java +Transform location = new Transform(new Point3D(0, 0, 500)); +TextCanvas canvas = new TextCanvas(location, "Hello, World!", + Color.WHITE, Color.BLACK); +shapeCollection.addShape(canvas); + +// blank canvas + cursor writing +TextCanvas blank = new TextCanvas(location, new TextPointer(10, 40), + Color.GREEN, Color.BLACK); +blank.locate(0, 0); +blank.print("Line 1"); +blank.locate(1, 0); +blank.print("Line 2"); +blank.setForegroundColor(Color.RED); // affects subsequent writes +blank.setTextColor(Color.CYAN); // recolors existing ink only +#+END_SRC + +Colors are per-cell: each =putChar= fills the cell's rectangle in the +background and foreground layers, so one canvas can hold many colors. + +*ForwardOrientedTextBlock* renders the same pipeline onto a billboard +that always faces the camera — for labels that must stay readable from +any angle: + +#+BEGIN_SRC java +ForwardOrientedTextBlock label = new ForwardOrientedTextBlock( + new Point3D(0, -50, 300), 1.0, 2, "Hello, World!", Color.RED); +shapeCollection.addShape(label); +#+END_SRC + +Real use in the demos: the life demo's help panel (=life_demo/Main.java= +=createHelpPanel()=) and the axis labels in =CoordinateSystemDemo=. + +* Tuning knobs +:PROPERTIES: +:CUSTOM_ID: tuning +:END: + +JVM properties (A/B tuning knobs in =TexturedTriangle=): + +| Property | Default | Effect | +|-------------------+---------+-------------------------------------------| +| =e3d.sdf.gamma= | 0 (auto) | fixed coverage gamma; auto derives from footprint | +| =e3d.sdf.sharpen= | 2 | coverage window narrowing; 1 = pixel-exact | +| =e3d.sdf.debug= | false | prints per-triangle footprints and path decisions to stderr | + +* Limitations +:PROPERTIES: +:CUSTOM_ID: limitations +:END: + +- *Fixed cell grid*: TextCanvas is monospace by construction (16x32 + texel cells). Proportional fonts would need a different stamping + scheme. +- *ASCII-oriented cache*: =SdfGlyphCache= measures printable ASCII + (33..126) when sizing the font; exotic glyphs may fit worse. +- *Under extreme minification* text fades to gray by design — that is + the correct physical answer (a sub-pixel glyph has no shape left), + but it means distant labels are decorative, not readable. +- *Bandwidth under minification*: sampling the primary field (no mip + chain) costs more bandwidth per pixel. Text surfaces are small, so + this is the right trade — do not attach SDF masks to huge surfaces. + +* Related classes +:PROPERTIES: +:CUSTOM_ID: related-classes +:END: + +| Class | Role | +|-----------------------------+--------------------------------------------------| +| [[file:../apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/TextCanvas.html][TextCanvas]] | Character grid surface in 3D; owns the layers | +| [[file:../apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/SdfGlyphCache.html][SdfGlyphCache]] | Per-glyph field generation + cache (EDT inside) | +| [[file:../apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/ForwardOrientedTextBlock.html][ForwardOrientedTextBlock]] | Camera-facing text billboard | +| [[file:../apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]] | =paintSdf= — the scanline path | +| [[file:../apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/texture/Texture.html][Texture]] | =sdfMask=, =sdfForeground=, =sdfSpreadTexels= | + +*See also:* + +- [[file:../Perspective correct textures/][Perspective-correct textures]] — the scanline texture-mapping path + that SDF rendering builds on; both paths live in =TexturedTriangle= + and share the same interpolated UVs. + diff --git a/doc/SDF textures/sdf-angled.png b/doc/SDF textures/sdf-angled.png new file mode 100644 index 0000000..34814bf Binary files /dev/null and b/doc/SDF textures/sdf-angled.png differ diff --git a/doc/SDF textures/sdf-far-zoom.png b/doc/SDF textures/sdf-far-zoom.png new file mode 100644 index 0000000..f0c0a44 Binary files /dev/null and b/doc/SDF textures/sdf-far-zoom.png differ diff --git a/doc/SDF textures/sdf-far.png b/doc/SDF textures/sdf-far.png new file mode 100644 index 0000000..64898a2 Binary files /dev/null and b/doc/SDF textures/sdf-far.png differ diff --git a/doc/SDF textures/sdf-mid.png b/doc/SDF textures/sdf-mid.png new file mode 100644 index 0000000..ba8ed3e Binary files /dev/null and b/doc/SDF textures/sdf-mid.png differ diff --git a/doc/SDF textures/sdf-near.png b/doc/SDF textures/sdf-near.png new file mode 100644 index 0000000..1d493a1 Binary files /dev/null and b/doc/SDF textures/sdf-near.png differ diff --git a/doc/Shading/Ambient light comparison.svg b/doc/Shading/Ambient light comparison.svg new file mode 100644 index 0000000..ce07a00 --- /dev/null +++ b/doc/Shading/Ambient light comparison.svg @@ -0,0 +1,51 @@ + + + + + + +Ambient Light +base illumination applied to all surfaces equally, regardless of orientation + + + + + + + + + + + + + + +Color(0, 0, 0) +✗ pure black +harsh shadows, no depth + + + + + + + + +Color(50, 50, 50) +✓ balanced +depth preserved + + + + + + + + +Color(150, 150, 150) +✗ too flat +no depth contrast + + +lightingManager.setAmbientLight(new Color(50, 50, 50)) ← default + \ No newline at end of file diff --git a/doc/Shading/Distance attenuation.svg b/doc/Shading/Distance attenuation.svg new file mode 100644 index 0000000..2edf492 --- /dev/null +++ b/doc/Shading/Distance attenuation.svg @@ -0,0 +1,91 @@ + + + + + + + + + +Distance Attenuation +light intensity falls off with distance from source + + + + + + + + + + + + +Light + + + + + + +0.99 + +d = 100 + + + + +0.52 + +d = 300 + + + + +0.29 + +d = 500 + +← attenuation factor shown above each surface → + + +attenuation vs distance + + +d +att + +0 + + +0.5 + + +1.0 + + +100 + + +300 + + +500 + + + + + +0.99 +0.52 +0.29 + + + +attenuation = +1 / (1 + 0.0001 · d²) + + +coefficient 0.0001 was tuned for typical scene scales in Aukio 3D + diff --git a/doc/Shading/Lambert cosine law.svg b/doc/Shading/Lambert cosine law.svg new file mode 100644 index 0000000..1f4e216 --- /dev/null +++ b/doc/Shading/Lambert cosine law.svg @@ -0,0 +1,92 @@ + + + + + + + + +Lambert Cosine Law +how surface orientation determines light intensity + + + + + + +N̂ +normal + + + + + + + + +Light + +L̂ + +θ +surface polygon + +brightness = +dot( N̂ , L̂ ) + += cos( θ ) + +θ = 0° + + +1.00 +θ = 45° + + +0.71 +θ = 90° + +0.00 + +θ > 90° +back-face → skip +dot < 0 → no contribution +— angle examples — + + + + + + θ = 0° + + + 100% + + + + + + + + 45° + θ = 45° + + + 71% + + + + + + + + 90° + θ = 90° + + 0% (skip) + + +N̂ surface normal + +L̂ light direction + diff --git a/doc/Shading/Shaded sphere.png b/doc/Shading/Shaded sphere.png new file mode 100644 index 0000000..fbc6487 Binary files /dev/null and b/doc/Shading/Shaded sphere.png differ diff --git a/doc/Shading/Shading pipeline.svg b/doc/Shading/Shading pipeline.svg new file mode 100644 index 0000000..a58c431 --- /dev/null +++ b/doc/Shading/Shading pipeline.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + Transform + compute lighting + + + + Shapes + + + Sort + + + Paint + use cached color + + + Blit + + + + + + + + diff --git a/doc/Shading/index.org b/doc/Shading/index.org new file mode 100644 index 0000000..fdf95a0 --- /dev/null +++ b/doc/Shading/index.org @@ -0,0 +1,266 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Shading & Lighting - Aukio 3D +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \usepackage[none]{hyphenat} + +#+OPTIONS: H:20 num:20 +#+OPTIONS: author:nil + +#+HTML_HEAD: + +[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]] + +* Overview +:PROPERTIES: +:CUSTOM_ID: shading-lighting +:END: + +#+attr_html: :class responsive-img +#+attr_latex: :width 1000px +[[file:Shaded sphere.png]] + +*Aukio 3D* implements *flat shading* using the [[https://en.wikipedia.org/wiki/Lambert%27s_cosine_law][Lambert cosine +law]]. Each polygon receives a single color based on its orientation +relative to light sources. This is a simple yet effective lighting +model that gives 3D objects depth and realism. + +** The Lighting Model: Lambert Cosine Law +:PROPERTIES: +:CUSTOM_ID: lambert-cosine-law +:END: + +#+INCLUDE: "Lambert cosine law.svg" export html + +The *Lambert cosine law* determines how much light a surface receives +based on its orientation. A surface facing directly toward a light source +receives maximum illumination; as it tilts away, the illumination decreases +proportionally until it reaches zero when perpendicular to the light +direction. This fundamental principle creates the visual cues that make 3D +objects appear solid and dimensional rather than flat. + +The engine implements this law through the dot product of two vectors. The +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html][LightingManager]] computes a unit vector pointing from the polygon's center +to each [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightSource.html][LightSource]], then calculates the dot product with the surface +normal. When the dot product equals 1.0, the surface faces the light +directly and receives full brightness. At 0.71 (a 45-degree angle), it +receives about 71% illumination. At zero or below, the surface faces away +from the light and receives no direct contribution from that source. The +implementation in [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html][LightingManager.computeLighting()]] explicitly checks for +positive dot products before adding light contributions, ensuring that +back-facing surfaces skip unnecessary calculations. + +The surface normal itself is computed by [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html#computeNormal()][Plane.computeNormal()]], which takes +the first three vertices of a polygon and calculates their cross product to +find the perpendicular direction. This normal, along with the polygon's +center point calculated by [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]], is passed to the lighting manager +during the [[file:../Rendering loop/][transform phase]] of the rendering loop. The transform phase runs +in parallel, but each polygon is transformed by exactly one worker per +pass, so its cached =shadedColor= field has a single writer — the +result is reused allocation-free during the subsequent multi-threaded +paint phase. See the +[[file:../index.org::#normal-vector][Normal Vector]] section for more details on how normals are computed and used +throughout the engine. + +* Light Sources +:PROPERTIES: +:CUSTOM_ID: light-sources +:END: + +Each light source has three properties: + +| Property | Description | +|------------+--------------------------------------| +| Position | 3D world coordinates of the light | +| Color | RGB color of emitted light | +| Intensity | Brightness multiplier (1.0 = normal) | + +#+BEGIN_SRC java +import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightSource; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; + +// Create a bright yellow light to the right +LightSource rightLight = new LightSource( + new Point3D(200, -100, 0), // position: right, above, at viewer level + Color.YELLOW, // color + 2.0 // intensity: extra bright +); + +// Create a dim blue light from the left +LightSource leftLight = new LightSource( + new Point3D(-150, 50, 100), + Color.BLUE, + 0.5 // intensity: dim +); +#+END_SRC + +Multiple light sources add their contributions together, allowing for +complex lighting setups like the screenshot above showing a sphere lit +by two lights from the right. + +** Distance Attenuation +:PROPERTIES: +:CUSTOM_ID: distance-attenuation +:END: + +#+INCLUDE: "Distance attenuation.svg" export html + +Light intensity decreases with distance using a *simplified inverse +square law*: + +#+BEGIN_SRC +attenuation = 1.0 / (1.0 + 0.0001 * distance²) +#+END_SRC + +- At distance 0: attenuation = 1.0 (full intensity) +- At distance 100: attenuation ≈ 0.99 (almost full) +- At distance 300: attenuation ≈ 0.52 (half intensity) +- At distance 500: attenuation ≈ 0.29 (about 30%) + +This simplified formula prevents harsh cutoffs while still providing +distance-based dimming. The =0.0001= coefficient was tuned for typical +scene scales in Aukio 3D. + +* Ambient Light +:PROPERTIES: +:CUSTOM_ID: ambient-light +:END: + +#+INCLUDE: "Ambient light comparison.svg" export html + +*Ambient light* provides base illumination that affects all surfaces +equally, regardless of orientation. Without ambient light, surfaces not +directly facing a light source would be pure black. + +- Default ambient: =Color(50, 50, 50)= (dim gray) — set by the ViewPanel + constructor; a standalone =new LightingManager()= starts at + =Color(10, 10, 10)= +- Configurable via =lightingManager.setAmbientLight()= +- Too much ambient: flat appearance (no contrast) +- Too little ambient: harsh shadows (pure black areas) + +#+BEGIN_SRC java +// Increase ambient for softer shadows +viewPanel.getLightingManager().setAmbientLight(new Color(80, 80, 80)); + +// Reduce ambient for dramatic contrast +viewPanel.getLightingManager().setAmbientLight(new Color(20, 20, 20)); +#+END_SRC + +* Using Shading in Your Scene +:PROPERTIES: +:CUSTOM_ID: using-shading +:END: + +**Adding light sources:** + +#+BEGIN_SRC java +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; +import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightSource; + +ViewPanel viewPanel = new ViewPanel(); + +// Get the lighting manager +LightingManager lighting = viewPanel.getLightingManager(); + +// Add light sources +lighting.addLight(new LightSource( + new Point3D(200, -100, 0), // right side, above + Color.YELLOW, + 1.5 // bright +)); + +lighting.addLight(new LightSource( + new Point3D(-100, 0, 200), // left side, further away + new Color(255, 200, 150), // warm white + 1.0 +)); + +// Configure ambient light +lighting.setAmbientLight(new Color(40, 40, 40)); +#+END_SRC + +**Enabling shading on shapes:** + +#+BEGIN_SRC java +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonRectangularBox; + +// Create a shaded box +SolidPolygonRectangularBox box = new SolidPolygonRectangularBox( + new Point3D(-50, -50, 100), // min corner + new Point3D(50, 50, 200), // max corner + Color.RED +); + +// Enable shading on the box and all its sub-polygons +box.setShadingEnabled(true); + +// Also enable backface culling for closed meshes +box.setBackfaceCulling(true); + +// Add to scene +viewPanel.getRootShapeCollection().addShape(box); +#+END_SRC + +Shading propagates through composite shapes — calling +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#setShadingEnabled(boolean)][setShadingEnabled(true)]] on a composite enables shading for all its +sub-polygons. + +** Related Classes +:PROPERTIES: +:CUSTOM_ID: related-classes +:END: + +| Class | Purpose | +|-------+---------| +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html][LightingManager]] | Manages light sources and computes shading | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightSource.html][LightSource]] | Individual light with position, color, intensity | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] | Polygon shape with shading support | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html][AbstractCompositeShape]] | Composite shape with shading propagation | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/ViewPanel.html][ViewPanel]] | Provides access to LightingManager | +* Implementation details +:PROPERTIES: +:CUSTOM_ID: implementation-details +:END: + +#+INCLUDE: "Shading pipeline.svg" export html + +Lighting is computed during *Phase 1* (transform phase) of the +[[file:../Rendering loop/][rendering loop]]: + +1. Each shaded polygon calculates its center point and surface normal +2. [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html][LightingManager]] computes lighting from all sources +3. Result stored in reusable =shadedColor= field +4. During *Phase 4* (paint), the cached color is used directly + +**Why during transform phase?** + +- Lighting computed *once per polygon per pass* — not per pixel +- Each polygon is transformed by a single worker, so its cached result + has exactly one writer even though the transform phase runs in parallel +- Result reused during multi-threaded paint phase — efficient + +** Performance Characteristics +:PROPERTIES: +:CUSTOM_ID: performance +:END: + +| Aspect | Cost | +|--------------+-------------------------------| +| Computation | Per polygon, not per pixel | +| Phase | Parallel transform (single writer per polygon) | +| Allocation | Zero (reuses Color instance) | +| Cache | One shadedColor per polygon | + +The shading implementation is optimized for CPU rendering: + +- *Flat shading*: One lighting calculation per polygon (N-vertex polygon = 1 calculation) +- *Reusable Color*: Result stored in existing field, no allocation during render +- *Thread-safe*: One writer per polygon per pass, so no synchronization needed +- *Pre-computed*: All paint workers (tile grid, ~75% of CPU cores by default) read the same cached result + +This approach trades visual fidelity (no per-pixel lighting) for +performance — essential for software rendering where per-pixel lighting +would be prohibitively expensive. diff --git a/doc/Stereoscopic rendering/Stereo geometry.svg b/doc/Stereoscopic rendering/Stereo geometry.svg new file mode 100644 index 0000000..2f62d50 --- /dev/null +++ b/doc/Stereoscopic rendering/Stereo geometry.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Two parallel cameras, one screen + top-down view of the scene (z grows downward = into the scene) + + + + screen plane (per eye) + + + + near object + + far object + + + + left eye + x - IPD/2 + + right eye + x + IPD/2 + + + + + + IPD = 6.5 units (cm) + + + + + + + + + + + + + + + + + + + + + + + + large disparity = close + + small disparity = far + + cameras stay PARALLEL (no toe-in) - depth comes purely from the x offset + diff --git a/doc/Stereoscopic rendering/Stereo per eye.svg b/doc/Stereoscopic rendering/Stereo per eye.svg new file mode 100644 index 0000000..5fc9cfd --- /dev/null +++ b/doc/Stereoscopic rendering/Stereo per eye.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + What changes per eye + + + + + concern + per-eye behavior + + + camera + translation.x += ±IPD/2 (restored after the pass) + + + projection + scale = eyeWidth/3; x += stereoViewportOffsetX + + + frustum culling + built from stereoViewportWidth - narrower FOV per eye + + + painting + clipped to [renderMinX, renderMaxX) = the eye's half + + + mouse picking + hits combined only for the eye containing the cursor + + + HUD / overlays + drawn once, spanning the full frame (zero disparity) + + + everything else - geometry, textures, lightmaps, GI - is shared: the scene is identical, only the viewpoint moves + diff --git a/doc/Stereoscopic rendering/Stereo pipeline.svg b/doc/Stereoscopic rendering/Stereo pipeline.svg new file mode 100644 index 0000000..895e1fc --- /dev/null +++ b/doc/Stereoscopic rendering/Stereo pipeline.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + One frame = two passes + the triple-buffered pipeline runs the same phases twice, once per eye + + + + camera + translation.x nudged +/- IPD/2 + + + + pass LEFT + transform → sort → tile-bin + viewport [0, w/2) + + + + pass RIGHT + transform → sort → tile-bin + viewport [w/2, w) + + + + + + + + each pass owns a RenderingContext copy + stereoEye, stereoViewportWidth/OffsetX, renderMinX..renderMaxX + + + + + + + + + + left eye pixels + painting clipped to left half + right eye pixels + painting clipped to right half + ONE shared frame buffer → one blit to screen (side-by-side image) + + + + + vertex buffers, aggregators and paint slots still cycle through 3 slots - the two passes overlap freely + diff --git a/doc/Stereoscopic rendering/index.org b/doc/Stereoscopic rendering/index.org new file mode 100644 index 0000000..f1ceee8 --- /dev/null +++ b/doc/Stereoscopic rendering/index.org @@ -0,0 +1,190 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Stereoscopic Rendering - Aukio 3D +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \setlength{\parindent}{15pt} +#+LATEX_HEADER: \usepackage{palatino} +#+LATEX_HEADER: \usepackage{charter} +#+HTML_HEAD: + +[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]] + +* What stereo rendering adds +:PROPERTIES: +:CUSTOM_ID: what-stereo-adds +:END: + +A single rendered image is flat: the brain infers depth only from +monocular cues (occlusion, shading, perspective, motion parallax while +you move). *Stereoscopic rendering* adds the strongest depth cue of +all — /binocular disparity/: your two eyes see slightly different +images, and the visual cortex turns the difference into a direct +sensation of depth. + +Aukio 3D implements the simplest and most portable form: *side-by-side +stereo*. Every frame renders the scene twice — once from the left eye +position, once from the right — into the left and right halves of the +same image. A VR headset, 3D TV, or a pair of XR glasses in +side-by-side mode feeds each half to the corresponding eye, and the +scene gains real volume. + +#+CAPTION: A side-by-side stereoscopic frame of the House demo, rendered headlessly with [[file:../index.org::#snapshot][the Snapshot tool]]. Left half: left eye. Right half: right eye. Compare the dark cube and the doorway between the halves — the horizontal shift is the disparity your brain reads as depth. +[[file:stereo-side-by-side.png]] + +* The geometry: two parallel cameras +:PROPERTIES: +:CUSTOM_ID: geometry +:END: + +The two eye cameras are identical to the mono camera except for one +thing: the left eye's position is shifted by -IPD/2 and the right +eye's by +IPD/2 along the *world* X axis (the offset is applied to the +camera translation's =x= component directly, then restored). IPD +(inter-pupillary distance) defaults to *6.5 world units* — the House +demo treats 1 unit as 1 cm, and 6.5 cm is the median human IPD. + +Both cameras look in exactly the same direction (*parallel cameras*, +no toe-in). Objects at different depths then land at different +horizontal offsets between the two images — that offset is the +disparity: + +#+CAPTION: Top-down view: the two eye positions and how a near and a far object project onto the screen plane. The near object separates much more between the eyes than the far one. +[[file:Stereo geometry.svg]] + +- near object -> large disparity -> feels close, +- far object -> small disparity -> feels far, +- object at infinity -> zero disparity. + +Larger IPD exaggerates disparity (stronger but potentially straining +depth); smaller IPD flattens the scene. =+=/=-= keys adjust it live in +0.5-unit steps while stereo is active. + +* One frame = two passes +:PROPERTIES: +:CUSTOM_ID: two-passes +:END: + +Stereo does not add a second pipeline — it runs the existing +triple-buffered pipeline *twice per frame*. The render thread in +=ViewPanel.renderFrame()= executes two render passes back to back: + +#+CAPTION: Per frame, the camera is nudged left, a full transform/sort/bin pass runs for the left viewport, then the camera is nudged right and a second pass runs for the right viewport. Both paint into one shared frame buffer, clipped to their half. +[[file:Stereo pipeline.svg]] + +1. *Pass LEFT:* camera translation.x is temporarily decreased by + IPD/2, the scene is transformed, depth-sorted and tile-binned into a + per-pass =RenderingContext= copy whose viewport is the left half of + the frame (=[0, width/2)=), and the paint continuation is submitted + to the worker pool. +2. *Pass RIGHT:* the same with +IPD/2 and the right viewport + (=[width/2, width)=). The camera offset is always restored in a + =finally= block, so the camera never drifts. +3. The two paints write into *one shared frame buffer* — each clipped + to its half — and the completed side-by-side image is blitted to + the screen in one go. + +The triple-buffer machinery (3 vertex slots, 3 aggregator slots, 3 +framebuffers) does not change: a *pass* takes the slot =passCounter % +3=, so left and right passes of the same frame simply occupy +consecutive slots and overlap exactly like consecutive mono frames do. +Workers flow from one pass's tiles straight into the next pass's tiles +with no idle gap. + +* What adapts per eye +:PROPERTIES: +:CUSTOM_ID: per-eye +:END: + +The scene itself — geometry, textures, lightmaps, global illumination +— is shared and identical for both eyes. Only the *viewpoint* moves, +so only view-dependent stages differ per pass: + +#+CAPTION: The per-eye surface area of the engine. Everything not listed here is eye-independent. +[[file:Stereo per eye.svg]] + +- *Projection:* =Vertex= projects with =projectionScale = eyeWidth/3= + (per-eye horizontal FOV) and adds =stereoViewportOffsetX= so the + projected image lands in the correct half of the buffer. The same + offset is applied for near-plane-clip vertices created directly in + camera space. +- *Frustum culling:* the frustum is rebuilt per pass from + =stereoViewportWidth=, so each eye culls against its own (narrower) + view volume — nothing leaks in from the other eye's half. +- *Painting:* every painter clips X to =[renderMinX, renderMaxX)=, + which the pass set to its viewport. No eye can paint into the other + half, even if a polygon crosses the center line. +- *Mouse picking:* in stereo each eye shows the same object at a + different screen X, so a hit can only be resolved against one eye. + =ViewPanel= combines mouse results only for the pass whose viewport + actually contains the cursor. +- *HUD/overlays:* developer tools, crosshair and text are drawn once + over the finished frame, at zero disparity — they sit on the screen + surface, not in the world. + +* Enabling stereo +:PROPERTIES: +:CUSTOM_ID: enabling +:END: + +#+BEGIN_SRC java +ViewPanel viewPanel = ...; + +// Side-by-side stereo on: +viewPanel.setStereoModeEnabled(true); + +// Optional: match the viewer (default 6.5 world units): +viewPanel.setStereoIPD(6.5); +#+END_SRC + +In the demos, *SHIFT+F11* toggles stereo and fullscreen together (XR +glasses want both); plain *F11* remains fullscreen-only. With stereo +active, *+* and *-* adjust the IPD in 0.5-unit steps (clamped at 0.5) +so the viewer can tune comfort at runtime. + +#+CAPTION: The same scene rendered as a normal mono frame (for comparison with the pair above). Notice there is no horizontal offset to read depth from — the picture is flat. +[[file:mono-comparison.png]] + +* Performance and limitations +:PROPERTIES: +:CUSTOM_ID: limitations +:END: + +- Stereo *doubles the per-frame transform, sort and paint work* — two + full passes instead of one. The pipeline overlaps them the same way + it overlaps consecutive mono frames, so throughput drops less than + 2x on a multi-core machine, but expect a real cost. +- Each eye gets *half the horizontal resolution* of the panel. On a + 1920x1080 fullscreen window each eye sees 960x1080 — pixels are + shared, not duplicated. +- IPD is in *world units*: 6.5 only means "6.5 cm" if the scene is + modeled at 1 unit = 1 cm. In a scene with a different scale, divide + or multiply accordingly — or just tune with =+=/=-= until the depth + feels right. +- The eye offset is applied along the *world X axis*, not the camera's + right vector: it is exactly correct when the camera faces along Z + (yaw = 0) and degrades as you turn — at yaw = 90° the eyes would be + offset front-to-back instead of side-to-side. For a fixed-viewing- + direction demo this is fine; a fully rotational stereo camera would + need to apply the IPD along the rotated right vector. +- Side-by-side is a *display format*, not a headset driver: the engine + produces the image; an XR viewer, 3D TV or video player is + responsible for delivering the halves to the eyes. +- Global illumination is unaffected: lightmaps live on the surfaces, + so both eyes sample the same converged lighting for free. + +* Related Classes +:PROPERTIES: +:CUSTOM_ID: related-classes +:END: + +| Class | Role in stereo rendering | +|----------------------+-----------------------------------------------------------------| +| =ViewPanel= | owns stereoModeEnabled/stereoIPD; runs the two passes per frame | +| =StereoEye= | NONE / LEFT / RIGHT tag carried by each pass context | +| =RenderingContext= | per-eye viewport fields: stereoViewportWidth/OffsetX, renderMin/MaxX | +| =Vertex= | per-eye projection: scale from eye width + viewport X offset | +| =ShapeCollection= | rebuilds the frustum per pass from the eye's viewport width | +| =InputManager= | SHIFT+F11 stereo toggle, +/- live IPD adjustment | + +[[file:../index.html#outline-container-understanding-3d-engine][Back to main documentation]] diff --git a/doc/Stereoscopic rendering/mono-comparison.png b/doc/Stereoscopic rendering/mono-comparison.png new file mode 100644 index 0000000..3b70ee0 Binary files /dev/null and b/doc/Stereoscopic rendering/mono-comparison.png differ diff --git a/doc/Stereoscopic rendering/stereo-side-by-side.png b/doc/Stereoscopic rendering/stereo-side-by-side.png new file mode 100644 index 0000000..12fa4fc Binary files /dev/null and b/doc/Stereoscopic rendering/stereo-side-by-side.png differ diff --git a/doc/Winding order.svg b/doc/Winding order.svg new file mode 100644 index 0000000..d82048e --- /dev/null +++ b/doc/Winding order.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + CCW + + + + V₁ + V₂ + V₃ + FRONT FACE ✓ + + + + + CW + + + BACK FACE ✗ + (culled — not drawn) + diff --git a/doc/export-docs.sh b/doc/export-docs.sh new file mode 100755 index 0000000..d55e8f2 --- /dev/null +++ b/doc/export-docs.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# export-docs.sh — export all org-mode documentation pages to HTML. +# +# Exports every doc/**/index.org (and doc/index.org) with the darksun +# theme, using the user's Emacs configuration. Run from anywhere: +# +# doc/export-docs.sh # export all pages +# doc/export-docs.sh --check # export, then render every page with +# # headless Chrome to /tmp/doc-check-*.png +# # for visual inspection +# +# Requires: emacs (with ~/.emacs providing the org HTML setup), +# google-chrome (only for --check). + +set -euo pipefail +DOC_DIR="$(cd "$(dirname "$0")" && pwd)" + +mapfile -t PAGES < <(find "$DOC_DIR" -name index.org | sort) + +echo "Exporting ${#PAGES[@]} pages..." +for page in "${PAGES[@]}"; do + rel="${page#"$DOC_DIR"/}" + if emacs --batch -l ~/.emacs --visit="$page" \ + --funcall=org-html-export-to-html --kill 2>&1 \ + | grep -qi "aborted\|unable to resolve link"; then + echo "FAIL $rel" + exit 1 + fi + echo " ok $rel" +done + +if [[ "${1:-}" == "--check" ]]; then + echo "Rendering pages for visual check..." + for page in "${PAGES[@]}"; do + rel="${page#"$DOC_DIR"/}" + html="${page%.org}.html" + out="/tmp/doc-check-$(echo "$rel" | tr '/ ' '__').png" + google-chrome --headless --disable-gpu --hide-scrollbars \ + --virtual-time-budget=8000 --window-size=1100,2000 \ + --screenshot="$out" "file://$html" 2>/dev/null + echo " shot $out" + done +fi + +echo "Done." diff --git a/doc/index.org b/doc/index.org new file mode 100644 index 0000000..4068d09 --- /dev/null +++ b/doc/index.org @@ -0,0 +1,1224 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Aukio 3D - Realtime 3D engine +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \usepackage[none]{hyphenat} + +#+OPTIONS: H:20 num:20 +#+OPTIONS: author:nil + +#+HTML_HEAD: + +* Introduction +:PROPERTIES: +:CUSTOM_ID: overview +:ID: a31a1f4d-5368-4fd9-aaf8-fa6d81851187 +:END: + +[[file:Example.png]] + +*Aukio 3D* is a realtime 3D rendering engine written in pure Java. It +runs entirely on the CPU — no GPU required, no OpenGL, no Vulkan, no +native libraries. Just Java. + +The motivation is simple: GPU-based 3D is a minefield of accidental +complexity. Drivers are buggy or missing entirely. Features you need +aren't supported on your target hardware. You run out of GPU RAM. You +wrestle with platform-specific interop layers, shader compilation +quirks, and dependency hell. Every GPU API comes with its own +ecosystem of pain — version mismatches, incomplete implementations, +vendor-specific workarounds. I want a library that "just works". + +*Aukio 3D* takes a different path. By rendering everything in software +on the CPU, the entire GPU problem space simply disappears. You add a +Maven dependency, write some Java, and you have a 3D scene. It runs +wherever Java runs. + +This approach is quite practical for many use-cases. Modern systems +ship with many CPU cores, and those with unified memory architectures +offer high bandwidth between CPU and RAM. Software rendering that once +seemed wasteful is now a reasonable choice where you need good-enough +performance without the overhead of a full GPU pipeline. Java's JIT +compiler helps too, optimizing hot rendering paths at runtime. + +Beyond convenience, CPU rendering gives you complete control. You own +every pixel. You can freely experiment with custom rendering +algorithms, optimization strategies, and visual effects without being +constrained by what a GPU API exposes. Instead of brute-forcing +everything through a fixed GPU pipeline, you can implement clever, +application-specific optimizations. + +*Aukio 3D* is part of the larger [[https://www3.svjatoslav.eu/projects/aukio/][Aukio project]], with the long-term goal +of providing a platform for 3D user interfaces and interactive data +visualization. It can also be used as a standalone 3D engine in any +Java project. See the [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/][demos]] for examples of what it can do today. + +*Major features:* +** Global Illumination +:PROPERTIES: +:CUSTOM_ID: global-illumination +:END: + +#+attr_html: :width 600px +#+attr_latex: :width 600px +[[file:Global illumination/Global illumination.png]] + +On top of flat shading, the engine computes progressive *global +illumination* on background CPU threads: real shadows, smooth light +falloff inside polygons (per-texel lightmaps), and indirect bounce +light — while the render loop itself never traces a single ray. + +Read more about [[file:Global illumination/][global illumination]]. + +** Side-by-side stereoscopic rendering support +:PROPERTIES: +:CUSTOM_ID: stereoscopic +:END: + +#+attr_html: :width 600px +#+attr_latex: :width 600px +[[file:Stereoscopic rendering/stereo-side-by-side.png]] + +The engine can render every frame twice — once per eye — into the left +and right halves of the same image, for XR glasses and 3D displays. +The cameras stay parallel and are offset by a configurable IPD +(inter-pupillary distance). Two render passes share one triple-buffered +pipeline, each clipped to its half of the frame buffer; per-eye +projection, frustum culling and mouse picking adapt automatically. + +See [[file:Stereoscopic%20rendering/][Stereoscopic rendering]] for the +geometry, pipeline and tuning. + +** Constructive Solid Geometry + +#+attr_html: :width 600px +#+attr_latex: :width 600px +[[file:CSG/CSG demo.png]] + +*Aukio 3D* allows performing boolean operations against geometry shapes. +So one can subtract, unionize or intersect shapes. + +To understand CSG boolean operations, read more about [[file:CSG/][Constructive +Solid Geometry]]. + +** SDF textures for sharp text +:PROPERTIES: +:CUSTOM_ID: sdf-text +:END: + +[[file:SDF textures/sdf-angled.png]] + +Text and vector-art surfaces do not store coverage; they store a +*signed distance field* — per texel, the distance to the nearest glyph +edge. The rasterizer re-derives coverage per screen pixel from that +smooth field, so text stays sharp at any zoom and fades to clean gray +under minification, all without a mipmap chain. + +See [[file:SDF%20textures/][SDF textures]] for the glyph pipeline, the +render path, analytic minification and tuning knobs. + +* How take engine into use +:PROPERTIES: +:CUSTOM_ID: taking-engine-into-use +:END: + +Add the *Aukio 3D* dependency to your Maven project: + +#+BEGIN_SRC xml + + + eu.svjatoslav + aukio-3d + 1.4 + + +#+END_SRC + +Also add the repository (the library is not on Maven Central): + +#+BEGIN_SRC xml + + + svjatoslav.eu + Svjatoslav repository + https://www3.svjatoslav.eu/maven/ + + +#+END_SRC + +- Library requires Java 21 or newer. + +- Study the [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/][demo applications]] for practical examples. Start with the + [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#minimal-example][minimal example]] to see the basic boilerplate needed to render a 3D + scene. + +- Study [[#understanding-3d-engine][how Aukio 3D engine works]]. +- Read online [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/][JavaDoc]]. +- See [[https://www3.svjatoslav.eu/projects/aukio-3d/graphs/][*Aukio 3D* class diagrams]]. (Diagrams were generated by using + [[https://www3.svjatoslav.eu/projects/javainspect/][JavaInspect]] utility) + +* Essential theory +:PROPERTIES: +:CUSTOM_ID: understanding-3d-engine +:ID: 4b6c1355-0afe-40c6-86c3-14bf8a11a8d0 +:END: +** Coordinate System (X, Y, Z) +:PROPERTIES: +:CUSTOM_ID: coordinate-system +:END: + +#+INCLUDE: "Coordinate system.svg" export html + +*Aukio 3D* uses a **left-handed coordinate system with X pointing right +and Y pointing down**, matching standard 2D screen coordinates. This +coordinate system should feel intuitive for people with preexisting 2D +graphics background. + +| Axis | Direction | Meaning | +|------+------------------------------------+-------------------------------------------| +| X | Horizontal, positive = RIGHT | Objects with larger X appear to the right | +| Y | Vertical, positive = DOWN | Lower Y = higher visually (up) | +| Z | Depth, positive = away from viewer | Negative Z = closer to camera | + +*Practical Examples* + +- A point at =(0, 0, 0)= is at the origin. +- A point at =(100, 50, 200)= is: 100 units right, 50 units down + visually, 200 units away from the camera. +- To place object A "above" object B, give A a **smaller Y value** + than B. + +Coordinates in this system are stored using the +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Point3D.html][Point3D]] class — a mutable container with public =x=, =y=, =z= fields +supporting vector operations like distance, rotation, and translation. +Vertices (see [[#vertex][below]]) are positioned within this coordinate system. + +The [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#coordinate-system][aukio-3d-demos]] project includes an interactive +coordinate system reference showing X, Y, Z axes as colored arrows +with a grid plane for spatial context. + +** Point3D and Vertex +:PROPERTIES: +:CUSTOM_ID: vertex +:END: + +#+INCLUDE: "Point3D vertex.svg" export html + +Every 3D object is built from *vertices* — corner points that define +the shape's geometry. A triangle has 3 vertices, a cube has 8, and +complex meshes have thousands. The engine uses two related classes to +represent points in 3D space, each serving a different purpose. + + + +*** Point3D — Raw Coordinates + +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Point3D.html][Point3D]] is the fundamental coordinate type throughout the engine. It +stores a position or vector with three public fields: =x=, =y=, =z=. +The class provides vector math operations: distance calculation, +rotation, translation, scaling, dot/cross products, and interpolation. Methods follow a fluent API convention where mutating +operations (like =add=, =multiply=) return =this= for chaining, while +non-mutating variants (like =withAdded=, =withMultiplied=) return new +instances. + +Use =Point3D= for: +- Storing positions, vectors, or any raw 3D coordinate +- Distance and angle calculations between points +- Vector math (dot product, cross product, normalization) +- Rotating or translating positions before shape construction + +#+BEGIN_SRC java +Point3D p1 = new Point3D(100, 50, 200); +Point3D p2 = new Point3D(0, 0, 100); +double distance = p1.getDistanceTo(p2); // Euclidean distance +Point3D direction = p1.withSubtracted(p2).unit(); // New point: unit vector from p2 to p1 +p1.rotate(new Point3D(0,0,0), Math.PI/4, 0); // Rotate p1 in place, 45° in XZ plane +#+END_SRC + +*** Vertex — Rendering-Ready Coordinates + +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/math/Vertex.html][Vertex]] wraps a =Point3D= and adds the coordinate spaces needed during +rendering. As a shape transforms through the render pipeline, each +vertex tracks its position in multiple spaces: + +| Field | Purpose | +|------------------------+--------------------------------------------------------| +| =coordinate= | Original position in local/model space | +| =transformedCoordinate(ctx)= | Position relative to camera (after transform stack) | +| =onScreenCoordinate(ctx)= | 2D screen pixels (after perspective projection) | +| =textureCoordinate= | Optional UV coords in pixel units (not normalized) | +| =normal= | Optional normal vector for CSG polygon splitting | + +=transformedCoordinate= and =onScreenCoordinate= are accessor methods, +not plain fields: each vertex carries three slots for each, one per +pipeline projection slot, and the accessor picks the slot of the +context's current render pass. This is what lets the triple-buffered +pipeline transform the next frame while previous frames are still +being painted (see [[file:Rendering loop/index.org::#software-pipeline][software pipeline]]). + +During rendering, the vertex is transformed through all spaces: first +applying the +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/math/TransformStack.html][TransformStack]] to get the camera-relative coordinate, then +projecting to 2D. Results are cached per frame per slot to avoid +recomputing for vertices shared across multiple shapes. + +Use =Vertex= when: +- Constructing triangles, polygons, or textured shapes +- Your geometry needs texture UV coordinates +- You're performing CSG boolean operations (requires =normal=) + +#+BEGIN_SRC java +// Create a textured triangle (texture coordinates use pixel units) +// For a 256x256 texture: (0,0)=top-left, (256,256)=bottom-right +Vertex v1 = new Vertex(new Point3D(0, 0, 100), new Point2D(0, 0)); +Vertex v2 = new Vertex(new Point3D(100, 0, 100), new Point2D(256, 0)); +Vertex v3 = new Vertex(new Point3D(50, 100, 100), new Point2D(128, 256)); +TexturedTriangle triangle = new TexturedTriangle(v1, v2, v3, texture); +#+END_SRC + +*** When to Use Each + +| Use Point3D | Use Vertex | +|--------------------------------------+-----------------------------------------------| +| Positioning shapes, cameras, lights | Building triangles and polygons | +| Vector math (distances, directions) | Texture-mapped geometry | +| Rotating or translating positions | CSG operations | +| Temporary calculations | Shapes that render through transform pipeline | + +For simple shapes without textures, you can pass raw =Point3D= +coordinates directly to constructors — the shape will internally wrap +them in =Vertex= objects. The [[#coordinate-system][coordinate system]] above defines the +meaning of all =x=, =y=, =z= values in both classes. + +** Edge +:PROPERTIES: +:CUSTOM_ID: edge +:END: + +#+INCLUDE: "Edge.svg" export html + +An *edge* is a straight line segment connecting two [[#vertex][vertices]]. Edges +form the wireframe skeleton of a 3D model — the structural framework +visible when surfaces are not rendered. A triangle has 3 edges, a cube +has 12 edges, and complex meshes have thousands. + +In *Aukio 3D*, the [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.html][Line]] class implements edges as renderable shapes. Each +Line connects two [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/math/Vertex.html][Vertex]] endpoints and stores two properties: a +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.html#width][width]] in world units (adjusted for perspective during rendering) and a +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.html#color][color]] with alpha transparency. The rendering algorithm switches +between two modes based on the projected screen width: thin lines below +the threshold are drawn as single pixels with alpha-adjusted coloring, +while thicker lines are rendered as filled rectangles with perspective-correct +edge fading using four [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineInterpolator.html][LineInterpolator]] scanline boundaries. + +Wireframe shapes are composite objects built from multiple Line instances. +For example, [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.html][WireframeBox]] creates 12 Line objects — four edges parallel to +each axis — using a [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineAppearance.html][LineAppearance]] factory to ensure consistent styling across +all edges. The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCube.html][WireframeCube]] convenience subclass provides a center-point +constructor. See the [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#shape-gallery][Shape Gallery demo]] for a visual comparison of +wireframe (edges only) versus solid polygon (surfaces with lighting) +rendering modes. + +** Face (Triangle) +:PROPERTIES: +:CUSTOM_ID: face-triangle +:END: + +#+INCLUDE: "Face triangle.svg" export html + +A *face* is a flat surface enclosed by edges — the visible skin of a 3D +object. While faces can theoretically have any number of sides, 3D +engines standardize on *triangles* because three points always define a +flat plane. A quad (4 vertices) or pentagon (5 vertices) might be +non-planar depending on vertex positions, causing rendering artifacts. +Triangles avoid this problem entirely. + +*** SolidPolygon — Solid-Color Faces + +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] is the primary face type, supporting any number of vertices +(3 or more). Triangles render directly via scanline rasterization. +N-vertex polygons (quads, pentagons, etc.) are triangulated using fan +decomposition — a quad becomes 2 triangles, a pentagon 3 — but only +when the polygon lives inside an +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html][AbstractCompositeShape]] +(the scene graph root is one): the composite triangulates while +building its render list. A standalone SolidPolygon with more than 3 +vertices cannot be painted directly and throws IllegalStateException. + +Each SolidPolygon stores a single fill color with optional alpha +transparency. When shading is enabled, the lighting manager computes +the polygon's illumination once during the transform phase, then +applies the shaded color during painting. Backface culling (see +[[#winding-order-backface-culling][Winding Order & Backface Culling]]) can be enabled per-polygon, or +applied recursively to an entire composite shape via +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#setBackfaceCulling(boolean)][AbstractCompositeShape.setBackfaceCulling(true)]] — this propagates the +setting to all SolidPolygon and TexturedTriangle sub-shapes, including +nested composites. + +#+BEGIN_SRC java +// Create a red triangle +SolidPolygon triangle = SolidPolygon.triangle( + new Point3D(0, 0, 100), + new Point3D(50, 0, 100), + new Point3D(25, 50, 100), + Color.RED +); + +// Create a blue quad (internally triangulated) +SolidPolygon quad = SolidPolygon.quad( + new Point3D(-50, -50, 100), + new Point3D(50, -50, 100), + new Point3D(50, 50, 100), + new Point3D(-50, 50, 100), + Color.BLUE +); + +// Enable lighting and culling for a closed mesh +quad.setShadingEnabled(true); +quad.setBackfaceCulling(true); + +// Add to the scene — the root composite triangulates the quad +viewPanel.getRootShapeCollection().addShape(quad); +#+END_SRC + +*** TexturedTriangle — UV-Mapped Faces + +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]] renders faces with image textures mapped via UV +coordinates. Each of the three [[#vertex][vertices]] stores a =textureCoordinate= +(a [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Point2D.html][Point2D]] with U and V values in *pixel units* matching the texture +dimensions). For a 256×256 texture, coordinates range from (0,0) at the +top-left corner to (256,256) at the bottom-right. During rasterization, the +engine interpolates these UV coordinates across the triangle's surface, +sampling the texture at each pixel. When mipmaps are used, the [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureBitmap.html#multiplicationFactor][multiplicationFactor]] +scales coordinates to match the selected mipmap resolution. + +The texture system supports mipmaps — pre-scaled versions of the texture +selected based on the triangle's screen size to reduce aliasing artifacts +on distant surfaces. Texture coordinates are mapped with +perspective-correct interpolation inside the scanline rasterizer, so +large triangles at steep angles render without distortion — see the +[[file:Perspective correct textures/][perspective-correct textures]] page. + +#+BEGIN_SRC java +// Create a 256x256 texture +Texture texture = new Texture(256, 256, 2); // width, height, maxUpscale + +// Create a textured triangle with UV coordinates in pixel units +Vertex v1 = new Vertex(new Point3D(0, 0, 100), new Point2D(0, 0)); // top-left +Vertex v2 = new Vertex(new Point3D(100, 0, 100), new Point2D(256, 0)); // top-right +Vertex v3 = new Vertex(new Point3D(50, 100, 100), new Point2D(128, 256)); // bottom-center + +TexturedTriangle triangle = new TexturedTriangle(v1, v2, v3, texture); +triangle.setBackfaceCulling(true); +#+END_SRC + +Both SolidPolygon and TexturedTriangle extend +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.html][AbstractCoordinateShape]], which handles vertex transformation and depth +sorting. See the [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#shape-gallery][Shape Gallery demo]] for a visual comparison of solid +versus textured polygon rendering. + +** Normal Vector +:PROPERTIES: +:CUSTOM_ID: normal-vector +:END: + +#+INCLUDE: "Normal vector.svg" export html + +A *normal* is a vector perpendicular to a surface. It tells the +renderer which direction a face is pointing. Normals are critical for +*lighting* — the angle between the light direction and the normal +determines how bright a surface appears. + +**Use cases:** + +| Use case | API | Computation | Location | +|----------------------+----------------------------------------------+----------------------------+-------------------| +| BSP/CSG operations | [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html#getPlane()][SolidPolygon.getPlane()]] → [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html#normal][Plane.normal]] | Lazy-cached once | =Plane= | +| Per-frame shading | [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html#computeNormal()][Plane.computeNormal()]] → =cachedNormal= field | Recomputed every frame | =SolidPolygon= | +| Lighting calculation | [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html#computeLighting()][LightingManager.computeLighting()]] | Uses normal via =dot(L,N)= | =LightingManager= | + +**Implementation notes:** +- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html#computeNormal()][Plane.computeNormal()]]: shared zero-allocation helper for computing normals from three points +- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html][Plane]]: stores normals in Hesse normal form (normal + distance) for BSP spatial partitioning +- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/math/Vertex.html#normal][Vertex.normal]]: optional field for CSG polygon splitting (not used for rendering) + +** Mesh +:PROPERTIES: +:CUSTOM_ID: mesh +:END: + +#+INCLUDE: "Mesh.svg" export html + +A *mesh* is a collection of vertices, edges, and faces that together +define the shape of a 3D object. Even curved surfaces like spheres are +approximated by many small triangles — more triangles means a smoother +appearance. A cube has 8 vertices forming 12 triangular faces, while a +smooth sphere requires hundreds or thousands of triangles depending on +the desired quality. + +In *Aukio 3D*, meshes are built through composition rather than +monolithic vertex/index buffers. The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.html][AbstractCoordinateShape]] class is +the foundation for primitive shapes — each instance stores its own +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.html#vertices][List<Vertex>]] directly. This includes [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] (N-vertex +convex polygons, not limited to triangles), [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]] +(UV-mapped triangles), and [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.html][Line]] (wireframe edges). The +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html][AbstractCompositeShape]] class groups multiple shapes into a single +object with its own position, rotation, and transform — useful for +complex models that move or rotate together. + +Complex meshes are constructed procedurally by adding primitive shapes +during initialization. For example, [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonSphere.html][SolidPolygonSphere]] generates +triangles using a latitude-longitude grid: with 16 segments, it +creates 960 SolidPolygon triangles by looping through +rings and sectors, calling [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#addShape(eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape)][addShape()]] for each. The generic +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.html][SolidPolygonMesh]] accepts any list of triangles, allowing custom +geometry from procedural generation or external sources. + +During rendering, several automatic optimizations occur. N-vertex +polygons (quads, pentagons, etc.) are triangulated using fan +triangulation inside the composite's render-list builder, converting +an N-vertex polygon into N-2 triangles. Textured triangles render with +perspective-correct texture mapping (see [[file:Perspective correct textures/][perspective-correct textures]]). +Composites perform view frustum culling to skip rendering when entirely +off-screen. Sub-shapes can be organized into named groups via [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/SubShape.html][SubShape]] +wrappers, allowing [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#showGroup(java.lang.String)][showGroup()]] and [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#hideGroup(java.lang.String)][hideGroup()]] to toggle visibility of +entire sections. Composite shapes also support CSG boolean operations +— see the [[file:CSG/][Constructive Solid Geometry]] documentation for union, +subtract, and intersect operations. + +The [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#shape-gallery][Shape Gallery demo]] showcases all primitive shapes available in +*Aukio 3D*, rendered in both wireframe mode (edges only via +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.html][WireframeBox]] and similar) and solid polygon mode (filled surfaces with +dynamic lighting). + +** Working with Colors +:PROPERTIES: +:CUSTOM_ID: working-with-colors +:ID: f2c9642a-a093-444f-8992-76c97ff28c16 +:END: + +Aukio 3D uses its own [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html][Color class]] instead of [[https://docs.oracle.com/en/java/javase/21/docs/api/java.desktop/java/awt/Color.html][java.awt.Color]]. This +custom implementation is designed specifically for the engine's +software rasterizer, where avoiding object allocation during rendering +is critical for performance. When rendering thousands of polygons per +frame, creating new Color instances for each one would generate +excessive garbage and trigger frequent garbage collection +pauses. Instead, the engine's Color class uses mutable fields that can +be reused across frames. + +The class stores RGBA components as public integer fields in the range +0–255. This format matches the engine's pixel buffer layout and avoids +costly float-to-int conversions during rasterization. The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#r][r]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#g][g]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#b][b]], and +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#a][a]] fields are accessible directly, allowing lighting calculations and +alpha blending to modify colors in-place without allocating new +objects. For example, the [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] class maintains a reusable +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][shadedColor]] field that gets updated during each frame's lighting +calculation instead of creating a new Color instance per polygon. + +Color provides several constructors for different input formats. The +most common approach is using hex strings via [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#hex(java.lang.String)][Color.hex(String)]] or the +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#%3Cinit%3E(java.lang.String)][String constructor]], which support formats like ="F80"= (3-digit RGB), +="FF8800"= (6-digit RGB), ="F808"= (4-digit RGBA), and ="FF8800CC"= +(8-digit RGBA). You can also create colors from integer RGBA +components (0–255) using [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#%3Cinit%3E(int,int,int,int)][new Color(r, g, b, a)]], from floating-point +components (0.0–1.0) via [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#%3Cinit%3E(double,double,double,double)][new Color(double r, double g, double b, +double a)]], or from a packed RGB integer like =0xFF8800= using [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#%3Cinit%3E(int)][new +Color(int rgb)]]. The class also provides predefined constants for +common colors: [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#RED][Color.RED]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#GREEN][Color.GREEN]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#BLUE][Color.BLUE]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#YELLOW][Color.YELLOW]], +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#CYAN][Color.CYAN]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#MAGENTA][Color.MAGENTA]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#WHITE][Color.WHITE]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#BLACK][Color.BLACK]], and +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#TRANSPARENT][Color.TRANSPARENT]]. + +The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#set(int,int,int,int)][set(int r, int g, int b, int a)]] method modifies a Color in-place +and returns =this= for method chaining, which is essential for +performance during rendering. For example, the [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html][LightingManager]] +calculates lighting contributions from all light sources and stores +the final shaded color directly into a reusable Color instance via +=set()=, avoiding any allocation. The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#toAwtColor()][toAwtColor()]] method converts a +Aukio 3D Color to a java.awt.Color when needed for Java2D graphics +operations, caching the result to avoid repeated conversion. The +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#toInt()][toInt()]] method packs the color into an ARGB integer suitable for the +engine's pixel buffer, used during rasterization to write pixels +directly. + +#+BEGIN_SRC java +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import static eu.svjatoslav.aukio.e3d.renderer.raster.Color.hex; + +// Using predefined color constants +Color red = Color.RED; +Color transparent = Color.TRANSPARENT; + +// Create from hex string (recommended for clarity) +Color orange = hex("FF8800"); // RGB, fully opaque +Color semiTransparent = hex("FF880080"); // RGBA, 50% transparent + +// Create from integer components (0-255) +Color custom = new Color(255, 128, 64, 200); + +// Create from packed RGB integer +Color packed = new Color(0xFF8800); + +// Modify existing color in-place (no allocation) +Color reusable = new Color(); +reusable.set(100, 200, 50, 255); + +// Convert to AWT color for Java2D operations +java.awt.Color awtColor = custom.toAwtColor(); + +// Use in lighting calculations (LightingManager modifies in-place) +// See the Shading & Lighting documentation for details +#+END_SRC + +The alpha component controls transparency during rendering. A value of +0 makes the color fully transparent, while 255 makes it fully +opaque. The rasterizer implements alpha blending during the paint +phase: when drawing a semi-transparent pixel, the engine blends the +source color with the existing background pixel proportionally based +on the alpha value. The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureBitmap.html#drawPixel(int,int\[\],int)][TextureBitmap.drawPixel()]] method handles this +blending, multiplying source colors by alpha and background colors by +=(255 - alpha)=, then combining them. You can test whether a color is +fully transparent using [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#isTransparent()][isTransparent()]], which returns true when alpha +equals zero. + +For lighting calculations, the [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightSource.html][LightSource]] class uses Color to +represent the color and intensity of emitted light. Multiple light +sources contribute to the final shaded color of each polygon, as +described in the [[file:Shading/index.org::#shading-lighting][Shading & Lighting]] documentation. The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html#setAmbientLight(eu.svjatoslav.aukio.e3d.renderer.raster.Color)][ambient light]] +provides base illumination that affects all surfaces equally, +regardless of orientation. Colors are also used for wireframe +rendering via the [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.html][Line]] class, where the color field determines the +line's appearance. + +** Shading & Lighting + +#+attr_html: :width 600px +#+attr_latex: :width 600px +[[file:Shading/Shaded%20sphere.png]] + + +*Aukio 3D* implements *flat shading* — one normal per polygon, +computed from the first three vertices. Each polygon receives a single +color based on its orientation relative to light sources. + +To understand lighting and shading, read more about [[file:Shading/][shading & lighting]]. + +* 3D engine internals +** Main render loop + +The rendering loop is the heart of the engine, continuously generating +frames at a target rate (typically 60 FPS). Each frame transforms 3D +shapes through a multi-stage pipeline before displaying them on screen. + +#+INCLUDE: "Rendering loop/Render pipeline.svg" export html + +The render loop runs on a dedicated background daemon thread managed by +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/ViewPanel.html][ViewPanel]], which can optionally sleep between frames to maintain a +target FPS or run unlimited for benchmarking. + +For a detailed walkthrough of each phase with diagrams and code +examples, see the dedicated page: [[file:Rendering loop/][Rendering loop]]. + +** Near-Plane Clipping +:PROPERTIES: +:CUSTOM_ID: near-plane-clipping +:END: + +Individual polygons that straddle the camera's near plane are not +dropped wholesale: the vertex loop is clipped against the plane, new +intersection vertices are generated with 3D-interpolated UVs and +normals, and the clipped polygon — a triangle can become a quad, +painted as a triangle fan — renders normally. Only polygons entirely +behind the near plane are culled. This keeps floor and wall tiles +visible when the camera brushes against them. + +#+INCLUDE: "Near plane clip/Near plane straddle.svg" export html + +Read more about [[file:Near plane clip/][near-plane clipping]]. + +** Depth buffer +:PROPERTIES: +:CUSTOM_ID: depth-buffer +:END: + +Visibility is resolved per pixel by a depth buffer: every triangle +interpolates =1/z= across its spans and wins a pixel only where it is +nearer than the surface already there. Opaque geometry — textured +triangles, solid polygons — paints front-to-back with depth writes; +translucent geometry paints back-to-front with depth tests but no +writes, so it never occludes. Lines and billboards stay painter-ordered +overlays by design. + +See [[file:Depth%20buffer/][Depth buffer]] for the full treatment. + +** Frustum & View Frustum Culling + +*Aukio 3D* implements view frustum culling. + +#+INCLUDE: "Frustum culling/Frustum diagram.svg" export html + +To understand frustum culling and object-level visibility +optimization, read more about [[file:Frustum culling/][frustum & view frustum culling.]] + +** Winding Order & Backface Culling +:PROPERTIES: +:CUSTOM_ID: winding-order-backface-culling +:END: + +#+INCLUDE: "Winding order.svg" export html + +The order in which a triangle's vertices are listed determines its +*winding order*. In *Aukio 3D*, screen coordinates have Y-axis pointing +*down*, which inverts the apparent winding direction compared to +standard mathematical convention (Y-up). *Counter-clockwise (CCW)* in +screen space means front-facing. *Backface culling* skips rendering +triangles that face away from the camera — a major performance +optimization. + +- CCW winding (in screen space) → front face (visible) +- CW winding (in screen space) → back face (culled) +- When viewing a polygon from outside: define vertices in *counter-clockwise* order as seen from the camera +- Saves ~50% of triangle rendering +- Implementation uses signed area: =signedArea < 0= means front-facing + (in Y-down screen coordinates, negative signed area corresponds to + visually CCW winding) + +In *Aukio 3D*, backface culling is *optional* and disabled by default. Enable it per-shape: +- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html#setBackfaceCulling(boolean)][SolidPolygon.setBackfaceCulling(true)]] +- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html#setBackfaceCulling(boolean)][TexturedTriangle.setBackfaceCulling(true)]] +- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#setBackfaceCulling(boolean)][AbstractCompositeShape.setBackfaceCulling(true)]] (applies to all + sub-shapes) + +See the [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#winding-order][Winding Order demo]] for an interactive visualization. + +** Perspective correct textures + +#+attr_html: :class responsive-img +#+attr_latex: :width 1000px +[[file:Perspective correct textures/Affine distortion.png]] + +*Aukio 3D* tries to do perspective-correct texture rendering. Read more +about [[file:Perspective correct textures/][perspective-correct texture implementation]]. + +* Developer tools +:PROPERTIES: +:CUSTOM_ID: developer-tools +:ID: 8c5e2a1f-9d3b-4f6a-b8e7-1c4d5f7a9b2e +:END: + +Press *F12* anywhere in the application to open the Developer Tools +panel: + +#+attr_html: :class responsive-img +#+attr_latex: :width 1000px +[[file:Developer tools/Developer tools.png]] + +This debugging interface helps you understand what the engine is doing +internally and diagnose rendering issues. Pressing F12 again closes +the panel. + +** Diagnostic toggles +:PROPERTIES: +:CUSTOM_ID: diagnostic-toggles +:END: + +*** Show polygon borders +:PROPERTIES: +:CUSTOM_ID: show-polygon-borders +:END: + +When enabled, each [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]] draws yellow outlines around its +three edges after rendering its texture content. This overlays the +triangle mesh onto the final image: + +#+attr_html: :class responsive-img +[[file:Developer tools/Render polygon borders.png]] + +Use this visualization when investigating: + +- Mesh structure: see the actual triangles as the rasterizer receives + them +- Geometry bugs: spot T-junction gaps and overlapping geometry +- Texture distortion: compare triangle shapes against visible warping + +*** Render alternate segments (overdraw debug) +:PROPERTIES: +:CUSTOM_ID: render-alternate-segments +:END: + +Renders only even-numbered paint tiles while leaving odd-numbered ones +black. (The screen is divided into a grid of rectangular tiles for +parallel rendering — see [[file:Rendering loop/index.org::#phase-4-clear-paint-tiles][the rendering loop documentation]]. +"Segments" is the older name for tiles.) + +#+attr_html: :class responsive-img +[[file:Developer tools/Render alternative segments.png]] + +This toggle helps detect overdraw: threads writing outside their +allocated tile. If you see rendering artifacts in the black tiles, a +paint task is writing pixels outside its assigned area — a clear sign +of a bug. + +*** Show segment boundaries +:PROPERTIES: +:CUSTOM_ID: show-segment-boundaries +:END: + +Draws red lines along the paint tile boundaries, making it easy to see +exactly where each tile's rendered area begins and ends. In stereo +mode each eye's viewport gets its own grid: + +#+attr_html: :class responsive-img +[[file:Developer tools/Show segment boundaries.png]] + +Useful for: + +- Verifying the tile grid division +- Debugging tile-boundary rendering issues (e.g. clipped text or + missing slivers at tile edges) +- Understanding the parallel rendering architecture visually + +** Camera position +:PROPERTIES: +:CUSTOM_ID: camera-position +:END: + +Displays the current camera coordinates and orientation in real-time: + +| Parameter | Description | +|-----------+------------------------------------------| +| x, y, z | Camera position in 3D world space | +| yaw | Rotation around the Y axis (left/right) | +| pitch | Rotation around the X axis (up/down) | +| roll | Rotation around the Z axis (tilt) | + +The *Copy* button copies the full camera position string to the +clipboard in a format ready to paste into bug reports or configuration +files. + +Use this for: +- Reporting exact camera positions when filing bugs +- Saving interesting viewpoints for later reference +- Understanding camera movement during navigation +- Sharing specific views with other developers + +Example copied format: +#+BEGIN_EXAMPLE +500.00, -300.00, -800.00, 0.60, -0.50, -0.00 +#+END_EXAMPLE + +The six numbers map 1:1 onto +[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/math/Transform.html#set(double,double,double,double,double,double)][Transform.set(x, y, z, yaw, pitch, roll)]], +so a copied viewpoint can be restored at startup — this is how the +demo applications freeze a good camera position into code: + +#+BEGIN_SRC java +// Camera position captured via Developer Tools -> Copy +viewPanel.getCamera().getTransform().set( + 130.66, -65.49, -248.18, // x, y, z + -0.06, -0.36, -0.00); // yaw, pitch, roll +#+END_SRC + +** Frustum culling statistics +:PROPERTIES: +:CUSTOM_ID: frustum-culling-statistics +:END: + +Shows real-time statistics about composite shape frustum culling +efficiency (see the dedicated [[file:Frustum culling/][frustum culling]] page for how +culling itself works): + +| Statistic | Description | +|-----------+----------------------------------------------------------| +| Total | Number of composite shapes tested against the frustum | +| Culled | Number of composites rejected (outside view frustum) | +| Culled % | Percentage of composites that were culled (0-100%) | + +*How to interpret the numbers:* + +- *High cull % (60-90%)*: Excellent — most objects are being correctly culled +- *Medium cull % (20-60%)*: Moderate — some optimization benefit +- *Low cull % (0-20%)*: Limited benefit — either all objects are visible, or scene needs restructuring + +*Example:* +#+BEGIN_EXAMPLE +Total: 473 Culled: 425 (89.9%) +#+END_EXAMPLE + +This means 473 composite shapes were tested, 425 were outside the view +and skipped entirely, and only 48 composites (with all their children) +actually needed to be rendered. This is excellent culling efficiency. + +The statistics update every 200ms while the panel is open. Note that +the root composite is never frustum-tested (it's always rendered), so +the "Total" count excludes it. + +** Render threads +:PROPERTIES: +:CUSTOM_ID: render-threads +:END: + +Shows the number of active render threads versus available CPU cores. +The engine defaults to 75% of available threads (at most cores − 1, so +one thread always stays free for the rest of the system). The count is +changeable at runtime via [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/ViewPanel.html#setNumRenderThreads(int)][ViewPanel.setNumRenderThreads(int)]]; +the worker pool is recreated lazily on the next frame. + +** Frame rate +:PROPERTIES: +:CUSTOM_ID: frame-rate +:END: + +Shows the current target FPS and the measured production rate (frames +completed per second, averaged over a ~500 ms window). The measured +number counts produced frames regardless of how quickly the display +path presents them — see [[file:Rendering loop/index.org::#frame-rate-control][frame rate control]]. + +The *Unlock FPS* toggle switches to unlimited (benchmark) mode: the +engine renders continuously as fast as possible, even when the scene +is static. Toggling off restores the previously locked target rate. + +** Thread activity timeline +:PROPERTIES: +:CUSTOM_ID: thread-activity-timeline +:END: + +A per-thread occupancy view — the software-renderer equivalent of a +GPU frame profiler. Each thread gets a row (the render thread and +present thread on top, then one row per worker), time runs along the X +axis, and each colored block is one recorded work interval. Idle time +is black. + +#+attr_html: :class responsive-img +[[file:Developer tools/Thread timeline.png]] + +Press *Record* to start capturing. The colors encode both the task +kind and which frame the task belongs to — transform, paint, and +binning come in three frame-parity variants (f0/f1/f2), so you can see +up to three frames in flight simultaneously. Additional colors mark +render-thread orchestration, blocked time, blits, and the sort/drain +sub-phases. + +Navigation: mouse wheel scrolls, Ctrl+wheel zooms, and a scrollbar +moves along the captured range. + +The legend colors, exactly as the timeline paints them: + +| Color | Legend label | What it shows | +|-------+--------------+---------------| +| @@html:@@ =#2ECC40= | =transform f0= | Vertex transform chunk task, frame slot 0 | +| @@html:@@ =#B8D900= | =transform f1= | Vertex transform chunk task, frame slot 1 | +| @@html:@@ =#6B8E23= | =transform f2= | Vertex transform chunk task, frame slot 2 | +| @@html:@@ =#0074D9= | =paint f0= | Paint tile task (clear + rasterize one tile), frame slot 0 | +| @@html:@@ =#F012BE= | =paint f1= | Paint tile task, frame slot 1 | +| @@html:@@ =#B10DC9= | =paint f2= | Paint tile task, frame slot 2 | +| @@html:@@ =#39CCCC= | =bin f0= | Tile binning task (assign sorted shapes to tiles), frame slot 0 | +| @@html:@@ =#008B8B= | =bin f1= | Tile binning task, frame slot 1 | +| @@html:@@ =#007070= | =bin f2= | Tile binning task, frame slot 2 | +| @@html:@@ =#A0A0A0= | =render serial= | Render thread orchestration: tree walk and pass submission | +| @@html:@@ =#8B0000= | =blocked= | Render thread waiting for an older paint pass or the present gate | +| @@html:@@ =#FFFFFF= | =blit= | Present thread copying a finished frame to the screen | +| @@html:@@ =#FF851B= | =sort+bin= | A pass's async continuation as a whole: drain, sort, bin, submit paint | +| @@html:@@ =#8B4513= | =drain= | Continuation sub-phase: await and merge parallel transform chunks | +| @@html:@@ =#FFD700= | =sort= | Continuation sub-phase: depth sort of the pass's shapes | + +The f0/f1/f2 suffixes are the frame's projection slot (frame number +modulo 3) — the triple-buffering from the [[file:Rendering loop/index.org::#software-pipeline][software pipeline]]. +When the pipeline is healthy you see interleaved colors from two or +three frames on the worker rows at once: paint tasks of an older frame +overlapping transform and binning of the newer one. Wide =blocked= +spans on the render row, or worker rows with black gaps, mean the +pipeline is starved rather than busy. + +Recording is cheap but not free (~100 ns per task; a single volatile +read when disabled), so leave it off during benchmarking runs. The +captured intervals live in a fixed-size ring buffer — long recordings +keep only the most recent history. + +What to look for: + +- *Solidly packed worker rows* mean the pipeline is keeping all cores + busy — the design goal (see [[file:Rendering loop/index.org::#software-pipeline][software pipeline]]) +- *Long "blocked" spans on the render row* mean the render thread is + waiting for paint passes — workers are the bottleneck +- *Long "blit" spans on the present row* mean the display path + (X server) is the bottleneck; excess frames are being dropped from + the presentation mailbox + +** Live log viewer +:PROPERTIES: +:CUSTOM_ID: live-log-viewer +:END: + +The scrollable text area shows captured debug output in real-time: +- Green text on black background for readability +- Auto-scrolls to show latest entries +- Updates every 200ms while panel is open +- Captures logs even when panel is closed (replays when reopened) + +Use the *Clear Logs* button to reset the log buffer for fresh +diagnostic captures. + +** Headless & agentic tooling +:PROPERTIES: +:CUSTOM_ID: headless-agentic +:END: + +For windowless rendering, pixel assertions, golden-image regression +tests and scene dumps — built for automated verification and AI agents — +see [[#agentic-development][agentic development tools]]. + +* Agentic development +:PROPERTIES: +:CUSTOM_ID: agentic-development +:END: + +*Aukio 3D* provides good support for automated AI coding agents +(for example OpenCode, Hermes Agent, etc..). + +Thanks to facilities is =eu.svjatoslav.aukio.e3d.headless= package, an +AI agent can render any scene from any pose, assert what got painted, +compare against committed reference images, and dump the full scene +state for a bug report — all without a window, a display, or the +render thread. + +** One pipeline, two drivers +:PROPERTIES: +:CUSTOM_ID: one-pipeline +:END: + +The key design decision: the headless path drives *the very same +transform → sort → paint pipeline* the on-screen ViewPanel +uses. Nothing is reimplemented, so a passing headless test proves the +real render works, and a bug reproduced headlessly is the real bug. + +#+INCLUDE: "Agentic development/Headless lanes.svg" export html + +Making this possible required three small engine changes: + +- ~ShapeCollection.transformShapes(Camera, RenderingContext)~ — the + transform phase now accepts a camera directly; the ViewPanel variant + just forwards its camera. Headless code never touches Swing. +- ~RenderingContext.getImage()~ — hands out the backing BufferedImage + the rasterizer paints into. +- ~GlobalIllumination.isRunning()~ / ~isConverged()~ / + ~getWorkItemCount()~ — GI state became inspectable. + +** Snapshot: render without a window +:PROPERTIES: +:CUSTOM_ID: snapshot +:END: + +#+BEGIN_SRC java +import eu.svjatoslav.aukio.e3d.headless.Snapshot; + +ShapeCollection scene = new ShapeCollection(); +scene.addShape(myShape); +LightingManager lighting = new LightingManager(); +lighting.setAmbientLight(Color.hex("181818")); + +// One call: build context, transform, sort, paint, return the image. +BufferedImage image = Snapshot.render(scene, lighting, + "290.31, -35.59, -2.10, -0.58, -0.15, 0.0", 640, 480); +Snapshot.save(image, "/tmp/snapshot.png"); +#+END_SRC + +The pose string is the *same "x, y, z, yaw, pitch, roll" format the +demos print* and users quote in bug reports — paste the pose, reproduce +the exact view. ~Snapshot.cameraFromPose()~ and ~Snapshot.poseString()~ +convert in both directions. + +For tests that need to detect *unpainted* pixels (holes), the +~renderInto()~ variant fills the background with a caller-chosen +sentinel color first, so "nothing was painted here" is unambiguous even +in a pitch-black scene: + +#+BEGIN_SRC java +RenderingContext ctx = new RenderingContext(640, 480, 1); +ctx.lightingManager = lighting; +Snapshot.renderInto(scene, camera, ctx, 0x00010203); // sentinel +#+END_SRC + +A real headless render — the House demo from a bug-report pose: + +#+attr_html: :class responsive-img +#+attr_latex: :width 640px +[[file:Agentic development/snapshot-example.png]] + +** PixelAssertions: did this region get painted? +:PROPERTIES: +:CUSTOM_ID: pixel-assertions +:END: + +The recurring debugging question — "did the floor actually render, or +did clipping eat it?" — becomes a library call: + +#+BEGIN_SRC java +import eu.svjatoslav.aukio.e3d.headless.PixelAssertions; + +// Fraction of a relative rectangle still equal to the background: +double holes = PixelAssertions.unpaintedFraction(image, 0, + 0.15, 0.45, 0.85, 1.0); // lower-center band +if (holes > 0.05) + throw new AssertionError("floor has holes: " + holes); + +long red = PixelAssertions.countColor(image, 0xFF0000); // flat-color tests +String grid = PixelAssertions.dumpPixelGrid(image, 320, 240, 3, 8); // hex dump +#+END_SRC + +#+INCLUDE: "Agentic development/Pixel assertion.svg" export html + +** GoldenImage: compare against a reference +:PROPERTIES: +:CUSTOM_ID: golden-image +:END: + +A pixel counts as different when any RGB channel drifts more than a +per-channel tolerance; the comparison fails when the fraction of +differing pixels exceeds a threshold. Deterministic flat-shaded renders +can use tight tolerances (4, 0.005); noisier paths relax them. + +#+BEGIN_SRC java +import eu.svjatoslav.aukio.e3d.headless.GoldenImage; + +GoldenImage.Result r = GoldenImage.compare(actual, + new File("goldens/house-flat.png"), 4, 0.005); +if (!r.passed) + GoldenImage.saveDiff(actual, goldenFile, "/tmp/diff.png"); // red = differs +#+END_SRC + +#+INCLUDE: "Agentic development/Golden workflow.svg" export html + +There is also a CLI for shell scripts — exit 0 = match, 1 = differ: + +#+BEGIN_SRC bash +java eu.svjatoslav.aukio.e3d.headless.GoldenImage actual.png golden.png 4 0.005 +#+END_SRC + +A real diff: the house rendered with the living-room lamp removed, +compared against the golden. The red region is exactly the room that +lost its light: + +#+attr_html: :class responsive-img +#+attr_latex: :width 640px +[[file:Agentic development/diff-example.png]] + +** SceneDump: the reproducible bug report +:PROPERTIES: +:CUSTOM_ID: scene-dump +:END: + +One call produces everything needed to reproduce what a frame shows: + +#+BEGIN_SRC java +System.out.println(SceneDump.dump(scene, lighting, camera, gi)); +#+END_SRC + +#+BEGIN_EXAMPLE +== SceneDump == +shapes: 5 top-level, 546 queued for rendering +lights: 4 (ambient #181818) + [0] pos=(-800.0, -240.0, 0.0) color=FFD890 intensity=6.0 + [1] pos=(0.0, -240.0, 0.0) color=D8E4FF intensity=5.0 + [2] pos=(800.0, -240.0, 0.0) color=FFB060 intensity=6.0 + [3] pos=(250.0, -60.0, -250.0) color=60FF90 intensity=2.0 +camera: 290.31, -35.59, -2.10, -0.58, -0.15, -0.00 +GI: running, 152034 work items, converged +#+END_EXAMPLE + +The camera line is a pose string — it feeds straight back into +~Snapshot.render()~. + +** HouseGoldens: ready-made regression tests +:PROPERTIES: +:CUSTOM_ID: house-goldens +:END: + +The aukio-3d-demos repo contains a working example of all of the above: +~eu.svjatoslav.aukio.e3d.examples.goldens.HouseGoldens~ renders the +House demo at two poses (the default view and the near-plane straddle +bug pose), compares both against committed goldens, and independently +asserts the floor has no holes. + +#+BEGIN_SRC bash +cd aukio-3d-demos +mvn clean package +mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt +java -cp "target/classes:$(cat cp.txt)" \ + eu.svjatoslav.aukio.e3d.examples.goldens.HouseGoldens # verify +java -cp "target/classes:$(cat cp.txt)" \ + eu.svjatoslav.aukio.e3d.examples.goldens.HouseGoldens --update # regenerate goldens +#+END_SRC + +Exit code 0 = all pass, 1 = any mismatch (with a diff PNG in /tmp). +Demos that want the same treatment expose their scene construction: +~HouseDemo.buildHouse()~, ~addFurniture()~ and ~addLights()~ are public +for exactly this reason. + +** export-docs.sh: regenerate the documentation +:PROPERTIES: +:CUSTOM_ID: export-docs +:END: + +All engine documentation (the pages you are reading) lives as org-mode +files under =doc/=. One script exports every page to HTML with the +darksun theme: + +#+BEGIN_SRC bash +doc/export-docs.sh # export all pages +doc/export-docs.sh --check # + render every page with headless Chrome + # to /tmp/doc-check-*.png for visual review +#+END_SRC + +The =--check= mode is how an agent verifies its own documentation: SVG +label collisions, broken image links and table breakage all show up in +the rendered screenshots. + +** A typical agent session +:PROPERTIES: +:CUSTOM_ID: typical-session +:END: + +#+BEGIN_EXAMPLE +1. Reproduce: Snapshot.render(scene, lighting, bugReportPose, 640, 480) +2. Inspect: SceneDump.dump(...) + view the PNG +3. Fix the engine +4. Verify: PixelAssertions.unpaintedFraction(...) == 0 +5. Regression: HouseGoldens (must stay ALL PASS) +6. Document: edit doc pages, export-docs.sh --check, review shots +#+END_EXAMPLE + +** Related Classes +:PROPERTIES: +:CUSTOM_ID: related-classes +:END: + +| Class | Purpose | +|-----------------+----------------------------------------------------| +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/headless/Snapshot.html][Snapshot]] | Windowless render facade + pose string conversion | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/headless/PixelAssertions.html][PixelAssertions]] | Painted-region / color-count / hex-grid assertions | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/headless/GoldenImage.html][GoldenImage]] | Golden-PNG comparison, diff writer, CLI | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/headless/SceneDump.html][SceneDump]] | Scene state as a reproducible text block | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.html][ShapeCollection]] | ~transformShapes(Camera, ...)~ headless overload | +| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/RenderingContext.html][RenderingContext]] | ~getImage()~ exposes the painted frame | + +* Source code +:PROPERTIES: +:CUSTOM_ID: source-code +:ID: 978b7ea2-e246-45d0-be76-4d561308e9f3 +:END: + +*This program is free software: released under Creative Commons Zero +(CC0) license* + +*Program author:* +- Svjatoslav Agejenko +- Homepage: https://svjatoslav.eu +- Email: mailto://svjatoslav@svjatoslav.eu +- See also: [[https://www.svjatoslav.eu/projects/][Other software projects hosted at svjatoslav.eu]] + +*Getting the source code:* +- [[https://www2.svjatoslav.eu/gitweb/?p=aukio-3d.git;a=snapshot;h=HEAD;sf=tgz][Download latest source code snapshot in TAR GZ format]] +- [[https://www2.svjatoslav.eu/gitweb/?p=aukio-3d.git;a=summary][Browse Git repository online]] +- Clone Git repository using command: + : git clone https://www3.svjatoslav.eu/git/aukio-3d.git diff --git a/doc/style.css b/doc/style.css new file mode 100644 index 0000000..3403e0e --- /dev/null +++ b/doc/style.css @@ -0,0 +1,35 @@ +.flex-center { + display: flex; + justify-content: center; +} + +.flex-center video { + width: min(90%, 1000px); + height: auto; +} + +.responsive-img { + width: min(100%, 1000px); + height: auto; +} + +/* === SVG diagram theme === */ +svg > rect:first-child { + fill: #061018; +} + +svg text[fill="#666"], +svg text[fill="#999"] { + fill: #aaa !important; +} + +svg line[stroke="#ccc"] { + stroke: #445566 !important; +} + +svg { + background-color: #061018; + border-radius: 8px; + display: block; + margin: 0 auto; +} \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..3cfe059 --- /dev/null +++ b/pom.xml @@ -0,0 +1,151 @@ + + 4.0.0 + eu.svjatoslav + aukio-3d + 1.5-SNAPSHOT + Aukio 3D + 3D engine + + + 21 + 21 + 21 + UTF-8 + UTF-8 + + + + svjatoslav.eu + https://svjatoslav.eu + + + + + net.java.dev.jna + jna + 5.14.0 + + + + junit + junit + 4.12 + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 21 + 21 + true + UTF-8 + + + + + org.apache.maven.plugins + maven-source-plugin + 2.2.1 + + + attach-sources + + jar + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 2.10.4 + + + attach-javadocs + + jar + + + + + + + + foo + bar + + + + ${java.home}/bin/javadoc + + + + + org.apache.maven.plugins + maven-resources-plugin + 2.4.3 + + UTF-8 + + + + + org.apache.maven.plugins + maven-release-plugin + 2.5.2 + + + org.apache.maven.scm + maven-scm-provider-gitexe + 1.9.4 + + + + + + + + org.apache.maven.wagon + wagon-ssh-external + 2.6 + + + + + + + + svjatoslav.eu + svjatoslav.eu + scpexe://svjatoslav.eu:10006/srv/maven + + + svjatoslav.eu + svjatoslav.eu + scpexe://svjatoslav.eu:10006/srv/maven + + + + + + svjatoslav.eu + Svjatoslav repository + https://www3.svjatoslav.eu/maven/ + + + + + scm:git:ssh://n0@svjatoslav.eu:10006/home/n0/git/aukio-3d.git + scm:git:ssh://n0@svjatoslav.eu:10006/home/n0/git/aukio-3d.git + HEAD + + + diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/diag/Diagnostics.java b/src/main/java/eu/svjatoslav/aukio/e3d/diag/Diagnostics.java new file mode 100644 index 0000000..28e86c5 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/diag/Diagnostics.java @@ -0,0 +1,37 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.diag; + +/** + * Facade that wires up Aukio diagnostics: persistent rolling log plus the + * periodic telemetry line. + * + *

Installed automatically when the first {@code ViewPanel} is created, + * or explicitly at application startup ({@code Diagnostics.install()} as + * the first statement of {@code main()}) so early boot output is captured + * too. Disable entirely with {@code -De3d.diagnostics=false}.

+ */ +public final class Diagnostics { + + private static volatile boolean installed = false; + + private Diagnostics() { + } + + /** Installs persistent logging and starts telemetry. Idempotent. */ + public static synchronized void install() { + if (installed) + return; + installed = true; + if ("false".equalsIgnoreCase( + System.getProperty("e3d.diagnostics", "true"))) { + System.out.println("[DIAG] diagnostics disabled" + + " (e3d.diagnostics=false)"); + return; + } + PersistentLog.install(); + Telemetry.start(); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/diag/EngineConfig.java b/src/main/java/eu/svjatoslav/aukio/e3d/diag/EngineConfig.java new file mode 100644 index 0000000..cd9a083 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/diag/EngineConfig.java @@ -0,0 +1,108 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.diag; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.Properties; + +/** + * User configuration for Aukio, read from a properties file in the user's + * home config directory. + * + *

Location: {@code ~/.config/aukio/config.properties} (override with + * {@code -De3d.config=}). A missing file means built-in defaults; + * unknown keys are ignored.

+ * + *

Recognized keys:

+ * + * + *

Every key can also be set as a system property + * ({@code -De3d.ipd=6.3}, {@code -De3d.bugreport.dir=...}, + * {@code -De3d.log.dir=...}, {@code -De3d.telemetry.interval=...}); + * system properties win over the config file.

+ */ +public final class EngineConfig { + + /** Default stereo IPD in world units (centimeters). */ + public static final double DEFAULT_IPD_CM = 6.5; + + private static final Properties PROPERTIES = new Properties(); + + static { + final File configFile = new File(System.getProperty("e3d.config", + System.getProperty("user.home") + + "/.config/aukio/config.properties")); + if (configFile.isFile()) { + try (FileInputStream in = new FileInputStream(configFile)) { + PROPERTIES.load(in); + } catch (final IOException e) { + System.err.println("[CONFIG] could not read " + configFile + + ": " + e.getMessage()); + } + } + } + + private EngineConfig() { + } + + /** + * Interpupillary distance for stereo rendering, in world units + * (centimeters). + */ + public static double getIpdCm() { + return parseDouble(System.getProperty("e3d.ipd", + PROPERTIES.getProperty("ipd.cm")), DEFAULT_IPD_CM); + } + + /** Directory under which bug reports are written. */ + public static File getBugReportDir() { + return new File(expandHome(System.getProperty("e3d.bugreport.dir", + PROPERTIES.getProperty("bugreport.dir", + "~/.local/share/aukio/bugreports")))); + } + + /** Directory for persistent rolling logs. */ + public static File getLogDir() { + return new File(expandHome(System.getProperty("e3d.log.dir", + PROPERTIES.getProperty("log.dir", + "~/.cache/aukio/logs")))); + } + + /** Telemetry write interval, seconds. */ + public static int getTelemetryIntervalSeconds() { + return (int) parseDouble( + System.getProperty("e3d.telemetry.interval", + PROPERTIES.getProperty("telemetry.interval.seconds")), + 5); + } + + private static double parseDouble(final String value, + final double fallback) { + if (value == null || value.isBlank()) + return fallback; + try { + return Double.parseDouble(value.trim()); + } catch (final NumberFormatException e) { + return fallback; + } + } + + private static String expandHome(final String path) { + if (path.startsWith("~/")) + return System.getProperty("user.home") + path.substring(1); + return path; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/diag/PersistentLog.java b/src/main/java/eu/svjatoslav/aukio/e3d/diag/PersistentLog.java new file mode 100644 index 0000000..fa61506 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/diag/PersistentLog.java @@ -0,0 +1,136 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.diag; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; + +/** + * Persistent rolling log: tees {@code System.out} and {@code System.err} + * into a log file on disk so a hung or crashed session still leaves its + * full output behind. + * + *

The file lives at {@code /aukio.log} and is rotated to + * {@code aukio.log.1} (one backup kept) once it exceeds + * {@value #MAX_LOG_BYTES}. Writes are flushed per line so the last output + * before an OutOfMemory freeze is on disk even when the application can + * no longer react to window events.

+ * + * @see Diagnostics + */ +public final class PersistentLog { + + /** Rotate the log once it exceeds this size. */ + private static final long MAX_LOG_BYTES = 4L * 1024 * 1024; + + private static volatile boolean installed = false; + private static volatile File logFile; + + private PersistentLog() { + } + + /** + * Installs the tee. Idempotent; failures (unwritable directory) fall + * back to console-only logging and are reported on stderr. + */ + public static synchronized void install() { + if (installed) + return; + installed = true; + + final File dir = EngineConfig.getLogDir(); + try { + Files.createDirectories(dir.toPath()); + logFile = new File(dir, "aukio.log"); + final SharedFileOutput shared = new SharedFileOutput(logFile); + System.setOut(new PrintStream( + new TeeStream(System.out, shared), true)); + System.setErr(new PrintStream( + new TeeStream(System.err, shared), true)); + System.out.println("[LOG] persistent log: " + + logFile.getAbsolutePath()); + } catch (final IOException e) { + System.err.println("[LOG] persistent logging unavailable in " + + dir + ": " + e.getMessage()); + logFile = null; + } + } + + /** The current log file, or null when persistent logging failed. */ + public static File getLogFile() { + return logFile; + } + + /** + * Single shared append stream to the log file; both the stdout and + * stderr tees write through it. Rotates the file once it grows past + * {@link #MAX_LOG_BYTES}. + */ + private static final class SharedFileOutput { + + private final File file; + private OutputStream out; + + SharedFileOutput(final File file) throws IOException { + this.file = file; + this.out = new FileOutputStream(file, true); + } + + synchronized void write(final int b) throws IOException { + rotateIfNeeded(); + out.write(b); + if (b == '\n') + out.flush(); + } + + synchronized void write(final byte[] b, final int off, + final int len) throws IOException { + rotateIfNeeded(); + out.write(b, off, len); + out.flush(); + } + + private void rotateIfNeeded() throws IOException { + if (file.length() < MAX_LOG_BYTES) + return; + out.close(); + final File backup = new File(file.getParentFile(), + file.getName() + ".1"); + Files.move(file.toPath(), backup.toPath(), + StandardCopyOption.REPLACE_EXISTING); + out = new FileOutputStream(file, true); + } + } + + /** Mirrors writes to the console and to the shared log file. */ + private static final class TeeStream extends OutputStream { + + private final PrintStream console; + private final SharedFileOutput file; + + TeeStream(final PrintStream console, final SharedFileOutput file) { + this.console = console; + this.file = file; + } + + @Override + public void write(final int b) throws IOException { + console.write(b); + file.write(b); + } + + @Override + public void write(final byte[] b, final int off, final int len) + throws IOException { + console.write(b, off, len); + file.write(b, off, len); + } + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/diag/Telemetry.java b/src/main/java/eu/svjatoslav/aukio/e3d/diag/Telemetry.java new file mode 100644 index 0000000..6d9ca92 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/diag/Telemetry.java @@ -0,0 +1,105 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.diag; + +import java.lang.management.ManagementFactory; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * Periodic telemetry line written to the persistent log: heap usage plus + * whatever counters registered subsystems report (streaming caches, + * loaded cells, queue depths, measured FPS...). + * + *

Slowdown-toward-OOM issues show up here as a monotonic climb in one + * of the numbers across a flight, which names the leaking resource. The + * line is written every {@code telemetry.interval.seconds} (default 5) + * and goes to stdout — the {@link PersistentLog} tee puts it on disk even + * when the application later freezes.

+ */ +public final class Telemetry { + + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private static final Map> SOURCES = + new ConcurrentHashMap<>(); + + private static volatile boolean started = false; + + private Telemetry() { + } + + /** + * Registers a named telemetry source. The supplier must be fast and + * side-effect free; it runs on the telemetry thread. Registering the + * same name again replaces the previous source. + * + * @param name short subsystem name, e.g. {@code "fo4"} + * @param source produces a compact stats string, e.g. + * {@code "cells=12 tris=450000"} + */ + public static void registerSource(final String name, + final Supplier source) { + SOURCES.put(name, source); + } + + /** Removes a previously registered source (subsystem shutdown). */ + public static void unregisterSource(final String name) { + SOURCES.remove(name); + } + + /** Starts the daemon telemetry thread. Idempotent. */ + public static synchronized void start() { + if (started) + return; + started = true; + final int intervalSeconds = + Math.max(1, EngineConfig.getTelemetryIntervalSeconds()); + final Thread thread = new Thread(() -> { + while (true) { + try { + Thread.sleep(intervalSeconds * 1000L); + } catch (final InterruptedException e) { + return; + } + try { + System.out.println(snapshotLine()); + } catch (final Throwable t) { + // telemetry must never take the application down + } + } + }, "aukio-telemetry"); + thread.setDaemon(true); + thread.start(); + } + + /** + * Builds the current telemetry line. Also used by bug reports to + * include a final snapshot. + */ + public static String snapshotLine() { + final Runtime rt = Runtime.getRuntime(); + final long used = rt.totalMemory() - rt.freeMemory(); + final StringBuilder sb = new StringBuilder("TELEMETRY "); + sb.append(LocalDateTime.now().format(TIME_FORMATTER)); + sb.append(String.format(" heap=%dM/%dM threads=%d", + used / (1024 * 1024), rt.maxMemory() / (1024 * 1024), + ManagementFactory.getThreadMXBean().getThreadCount())); + for (final Map.Entry> entry + : SOURCES.entrySet()) { + try { + sb.append(' ').append(entry.getKey()).append("={") + .append(entry.getValue().get()).append('}'); + } catch (final Throwable t) { + sb.append(' ').append(entry.getKey()).append("={error}"); + } + } + return sb.toString(); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Box.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Box.java new file mode 100644 index 0000000..7ae2362 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Box.java @@ -0,0 +1,216 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.geometry; + +import static java.lang.Math.abs; + +/** + * A 3D axis-aligned bounding box defined by two corner points. + * + *

Also known as: 3D rectangle, rectangular box, rectangular parallelepiped, + * cuboid, rhomboid, hexahedron, or rectangular prism.

+ * + *

The box is defined by two points ({@link #p1} and {@link #p2}) that represent + * opposite corners. The box does not enforce ordering of these points.

+ * + *

Example usage:

+ *
{@code
+ * Box box = new Box(new Point3D(0, 0, 0), new Point3D(100, 50, 200));
+ * double volume = box.getWidth() * box.getHeight() * box.getDepth();
+ * box.enlarge(10);  // expand by 10 units in all directions
+ * }
+ * + * @see Point3D + */ +public class Box implements Cloneable { + + /** + * The first corner point of the box. + */ + public final Point3D p1; + /** + * The second corner point of the box (opposite corner from p1). + */ + public final Point3D p2; + + /** + * Creates a new box with both corner points at the origin. + */ + public Box() { + p1 = new Point3D(); + p2 = new Point3D(); + } + + /** + * Creates a new box with the specified corner points. + * + * @param p1 the first corner point + * @param p2 the second corner point (opposite corner) + */ + public Box(final Point3D p1, final Point3D p2) { + this.p1 = p1; + this.p2 = p2; + } + + + /** + * Enlarges the box by the specified border in all directions. + * + * @param border The border to enlarge the box by. + * If the border is negative, the box will be shrunk. + * @return The current box. + */ + public Box enlarge(final double border) { + + if (p1.x < p2.x) { + p1.translateX(-border); + p2.translateX(border); + } else { + p1.translateX(border); + p2.translateX(-border); + } + + if (p1.y < p2.y) { + p1.translateY(-border); + p2.translateY(border); + } else { + p1.translateY(border); + p2.translateY(-border); + } + + if (p1.z < p2.z) { + p1.translateZ(-border); + p2.translateZ(border); + } else { + p1.translateZ(border); + p2.translateZ(-border); + } + + return this; + } + + /** + * Creates a copy of this box with cloned corner points. + * + * @return a new box with the same corner coordinates + */ + @Override + public Box clone() { + return new Box(p1.clone(), p2.clone()); + } + + /** + * Returns the depth of the box (distance along the Z-axis). + * + * @return the depth (always positive) + */ + public double getDepth() { + return abs(p1.z - p2.z); + } + + /** + * Returns the height of the box (distance along the Y-axis). + * + * @return the height (always positive) + */ + public double getHeight() { + return abs(p1.y - p2.y); + } + + /** + * Returns the width of the box (distance along the X-axis). + * + * @return the width (always positive) + */ + public double getWidth() { + return abs(p1.x - p2.x); + } + + + /** + * Sets the size of the box. The box will be centered at the origin. + * Previous size and position of the box will be lost. + * + * @param size {@link Point3D} specifies box size in x, y and z axis. + */ + public void setBoxSize(final Point3D size) { + p2.clone(size).divide(2); + p1.clone(p2).negate(); + } + + /** + * Returns the minimum X coordinate of this box. + * Useful for AABB intersection tests. + * + * @return the smaller X value of p1 and p2 + */ + public double getMinX() { + return Math.min(p1.x, p2.x); + } + + /** + * Returns the maximum X coordinate of this box. + * Useful for AABB intersection tests. + * + * @return the larger X value of p1 and p2 + */ + public double getMaxX() { + return Math.max(p1.x, p2.x); + } + + /** + * Returns the minimum Y coordinate of this box. + * Useful for AABB intersection tests. + * + * @return the smaller Y value of p1 and p2 + */ + public double getMinY() { + return Math.min(p1.y, p2.y); + } + + /** + * Returns the maximum Y coordinate of this box. + * Useful for AABB intersection tests. + * + * @return the larger Y value of p1 and p2 + */ + public double getMaxY() { + return Math.max(p1.y, p2.y); + } + + /** + * Returns the minimum Z coordinate of this box. + * Useful for AABB intersection tests. + * + * @return the smaller Z value of p1 and p2 + */ + public double getMinZ() { + return Math.min(p1.z, p2.z); + } + + /** + * Returns the maximum Z coordinate of this box. + * Useful for AABB intersection tests. + * + * @return the larger Z value of p1 and p2 + */ + public double getMaxZ() { + return Math.max(p1.z, p2.z); + } + + /** + * Returns the geometric center of this box. + * + * @return a new Point3D at the center of the box + */ + public Point3D getCenter() { + return new Point3D( + (p1.x + p2.x) / 2.0, + (p1.y + p2.y) / 2.0, + (p1.z + p2.z) / 2.0 + ); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/BspTree.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/BspTree.java new file mode 100644 index 0000000..f6542b2 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/BspTree.java @@ -0,0 +1,230 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.geometry; + +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; + +import java.util.ArrayList; +import java.util.List; + +/** + * A Binary Space Partitioning (BSP) tree for CSG operations. + * + *

BSP trees are the data structure that makes CSG boolean operations possible. + * Each node divides 3D space into two half-spaces using a plane, enabling + * efficient spatial queries and polygon clipping.

+ * + *

BSP Tree Structure:

+ *
+ *                 [Node: plane P]
+ *                /               \
+ *        [Front subtree]     [Back subtree]
+ *     (same side as P's     (opposite side
+ *        normal)             of P's normal)
+ * 
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape + * @see Plane the plane type used for spatial partitioning + * @see SolidPolygon the polygon type stored in BSP nodes + */ +public class BspTree { + + /** + * Polygons that lie on this node's partitioning plane. + */ + public final List polygons = new ArrayList<>(); + + /** + * The partitioning plane for this node. + */ + public Plane plane; + + /** + * The front child subtree. + */ + public BspTree front; + + /** + * The back child subtree. + */ + public BspTree back; + + /** + * Creates an empty BSP tree with no plane or children. + */ + public BspTree() { + } + + /** + * Creates a BSP tree from a list of polygons. + * + * @param polygons the polygons to partition into a BSP tree + */ + public BspTree(final List polygons) { + addPolygons(polygons); + } + + /** + * Creates a deep clone of this BSP tree. + * + * @return a new BspTree with cloned data + */ + public BspTree clone() { + final BspTree tree = new BspTree(); + + tree.plane = plane != null ? plane.clone() : null; + tree.front = front != null ? front.clone() : null; + tree.back = back != null ? back.clone() : null; + + for (final SolidPolygon p : polygons) { + tree.polygons.add(p.deepClone()); + } + + return tree; + } + + /** + * Inverts this BSP tree, converting "inside" to "outside" and vice versa. + */ + public void invert() { + for (final SolidPolygon polygon : polygons) polygon.flip(); + + if (plane != null) plane.flip(); + if (front != null) front.invert(); + if (back != null) back.invert(); + + final BspTree temp = front; + front = back; + back = temp; + } + + /** + * Clips a list of polygons against this BSP tree, returning only the + * portions that lie outside the solid represented by this tree. + * + *

This is a core CSG operation used for boolean subtraction and + * intersection. The method recursively traverses the BSP tree, splitting + * polygons at each partitioning plane and discarding interior fragments.

+ * + *

Algorithm:

+ *
    + *
  1. At each node, split polygons by the partitioning plane
  2. + *
  3. Recursively clip front fragments against the front subtree
  4. + *
  5. Recursively clip back fragments against the back subtree
  6. + *
  7. Combine and return all surviving fragments
  8. + *
+ * + *

Leaf nodes: If this node has no plane (leaf node), all polygons + * are considered outside and returned unchanged.

+ * + * @param polygons the polygons to clip against this BSP tree + * @return a new list containing only the portions outside this solid + */ + public List clipPolygons(final List polygons) { + // Leaf node: no partitioning plane means all polygons are outside + if (plane == null) { + return new ArrayList<>(polygons); + } + + // Split polygons by this node's partitioning plane + final List frontList = new ArrayList<>(); + final List backList = new ArrayList<>(); + + for (final SolidPolygon polygon : polygons) + // Split by plane: coplanar polygons are classified by their normal direction + // (same-facing normal → frontList, opposite-facing normal → backList) + plane.splitPolygon(polygon, frontList, backList, frontList, backList); + + // Recursively clip front fragments against front subtree + List resultFront = frontList; + if (front != null) resultFront = front.clipPolygons(frontList); + + // Recursively clip back fragments against back subtree + List resultBack; + if (back != null) resultBack = back.clipPolygons(backList); + else resultBack = new ArrayList<>(); + + // Combine surviving fragments from both subtrees + final List result = new ArrayList<>(resultFront.size() + resultBack.size()); + result.addAll(resultFront); + result.addAll(resultBack); + return result; + } + + /** + * Clips this BSP tree against another BSP tree. + * + * @param bsp the BSP tree to clip against + */ + public void clipTo(final BspTree bsp) { + final List newPolygons = bsp.clipPolygons(polygons); + polygons.clear(); + polygons.addAll(newPolygons); + + if (front != null) front.clipTo(bsp); + if (back != null) back.clipTo(bsp); + } + + /** + * Collects all polygons from this BSP tree into a flat list. + * + * @return a new list containing all polygons in this tree + */ + public List allPolygons() { + final List result = new ArrayList<>(polygons); + + if (front != null) result.addAll(front.allPolygons()); + if (back != null) result.addAll(back.allPolygons()); + + return result; + } + + /** + * Adds polygons to this BSP tree, partitioning space recursively. + * + *

This method is the core BSP tree construction algorithm. It builds or + * extends the tree by choosing a partition plane and classifying each polygon:

+ * + *
    + *
  • Coplanar — polygons on the partition plane are stored in this node
  • + *
  • Front — polygons in the front half-space (same side as plane normal) + * go to the front child subtree
  • + *
  • Back — polygons in the back half-space (opposite to plane normal) + * go to the back child subtree
  • + *
  • Spanning — polygons crossing the plane are split into front and back + * fragments, each going to its respective subtree
  • + *
+ * + *

For an empty tree, the first polygon's plane becomes the partition plane. + * Child nodes are created lazily when polygons need to be stored in them.

+ * + *

Can be called multiple times to incrementally extend an existing tree, + * though the original partition planes remain unchanged.

+ * + * @param polygons the polygons to insert into this BSP tree + * @see Plane#splitPolygon the method that classifies and splits individual polygons + */ + public void addPolygons(final List polygons) { + if (polygons.isEmpty()) return; + + if (plane == null) plane = polygons.get(0).getPlane().clone(); + + final List frontList = new ArrayList<>(); + final List backList = new ArrayList<>(); + + for (final SolidPolygon polygon : polygons) + plane.splitPolygon(polygon, this.polygons, this.polygons, frontList, backList); + + if (!frontList.isEmpty()) { + if (front == null) front = new BspTree(); + front.addPolygons(frontList); + } + + if (!backList.isEmpty()) { + if (back == null) back = new BspTree(); + back.addPolygons(backList); + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Circle.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Circle.java new file mode 100644 index 0000000..70eca94 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Circle.java @@ -0,0 +1,30 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.geometry; + +/** + * A circle in 2D space defined by a center point and radius. + * + * @see Point2D + */ +public class Circle { + + /** + * The center point of the circle. + */ + Point2D location; + + /** + * The radius of the circle. + */ + double radius; + + /** + * Creates a circle with default values. + */ + public Circle() { + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Frustum.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Frustum.java new file mode 100644 index 0000000..d1075c6 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Frustum.java @@ -0,0 +1,266 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.geometry; + +import eu.svjatoslav.aukio.e3d.gui.Camera; + +/** + * View frustum for frustum culling - eliminates objects outside the camera's view. + * + *

The frustum is a truncated pyramid-shaped volume that represents everything + * the camera can see. Objects completely outside this volume can be skipped + * during rendering, significantly improving performance for large scenes.

+ * + *

Frustum planes:

+ *
    + *
  • Left, Right, Top, Bottom - define the viewport edges
  • + *
  • Near - closest visible distance from camera
  • + *
  • Far - farthest visible distance from camera
  • + *
+ * + *

Usage:

+ *
{@code
+ * Frustum frustum = new Frustum();
+ * frustum.update(camera, screenWidth, screenHeight);
+ *
+ * Box objectBounds = shape.getBoundingBox();
+ * if (frustum.intersectsAABB(objectBounds)) {
+ *     // Object is potentially visible - render it
+ * } else {
+ *     // Object outside frustum - skip rendering
+ * }
+ * }
+ * + *

AABB intersection algorithm:

+ *

Uses the optimized "P-vertex" approach: for each plane, we test only + * the AABB corner most aligned with the plane normal. If this corner is + * behind the plane, the entire AABB is outside the frustum.

+ * + * @see Box axis-aligned bounding box for culling tests + * @see Camera provides position and orientation for frustum computation + */ +public class Frustum { + + /** + * Index for the left clipping plane. + */ + public static final int LEFT = 0; + /** + * Index for the right clipping plane. + */ + public static final int RIGHT = 1; + /** + * Index for the top clipping plane. + */ + public static final int TOP = 2; + /** + * Index for the bottom clipping plane. + */ + public static final int BOTTOM = 3; + /** + * Index for the near clipping plane. + */ + public static final int NEAR = 4; + /** + * Index for the far clipping plane. + */ + public static final int FAR = 5; + + /** + * The six clipping planes defining the frustum volume. + * Each plane is stored as (normal, distance) in Hesse normal form. + * Planes are in world space coordinates. + */ + private final Plane[] planes = new Plane[6]; + + /** + * Default near plane distance from camera (in world units). + * Objects closer than this are culled. + */ + private double nearDistance = 1.0; + + /** + * Default far plane distance from camera (in world units). + * Objects farther than this are culled. + */ + private double farDistance = 10000.0; + + /** + * Creates a new frustum with uninitialized planes. + * Call {@link #update} before using for culling. + */ + public Frustum() { + for (int i = 0; i < 6; i++) { + planes[i] = new Plane(new Point3D(0, 0, 1), 0); + } + } + + /** + * Updates the frustum planes in view space (camera at origin, looking along +Z). + * + *

This method should be called once per frame before rendering, after the + * camera position and orientation have been updated.

+ * + *

View space coordinate system:

+ *
    + *
  • Camera at origin (0, 0, 0)
  • + *
  • Forward = +Z axis (looking into the screen)
  • + *
  • Right = +X axis
  • + *
  • Up = -Y axis (since Y-down means smaller Y is higher visually)
  • + *
+ * + *

Plane normals point INTO the frustum (toward the visible volume). + * A point is inside if dot(normal, point) >= distance for all planes.

+ * + *

FOV calculation: The Aukio 3D engine uses projectionScale = width/3. + * This means tan(halfHFOV) = (width/2) / projectionScale = 1.5, giving a + * horizontal FOV of approximately 112 degrees.

+ * + * @param camera the camera (used only for aspect ratio derivation from width/height) + * @param width the viewport width in pixels (defines projectionScale) + * @param height the viewport height in pixels (used for vertical FOV) + */ + public void update(final Camera camera, final int width, final int height) { + // Frustum is computed in VIEW SPACE (camera at origin, looking along +Z) + // This matches the coordinate system after applying camera transforms + + // Aukio 3D uses projectionScale = width/3 + // tan(halfFOV) = (halfSize) / projectionScale + final double projectionScale = width / 3.0; + final double tanHalfHFOV = (width / 2.0) / projectionScale; // = 1.5 (very wide FOV) + final double tanHalfVFOV = (height / 2.0) / projectionScale; // depends on aspect ratio + + // Compute cosine and sine of half-FOV angles + // cosHalfFOV = 1 / sqrt(1 + tanHalfFOV^2) + // sinHalfFOV = tanHalfFOV * cosHalfFOV + final double cosHalfHFOV = 1.0 / Math.sqrt(1.0 + tanHalfHFOV * tanHalfHFOV); + final double sinHalfHFOV = tanHalfHFOV * cosHalfHFOV; + final double cosHalfVFOV = 1.0 / Math.sqrt(1.0 + tanHalfVFOV * tanHalfVFOV); + final double sinHalfVFOV = tanHalfVFOV * cosHalfVFOV; + + // Near and far distances + nearDistance = 1.0; + farDistance = 10000.0; + + // All side planes pass through origin (camera position in view space) + // Plane equation: dot(normal, point) >= distance means inside + + // Left plane: inward normal pointing right-forward + // Bounds: x >= -tanHalfHFOV * z (to the right of left edge) + planes[LEFT].normal = new Point3D(cosHalfHFOV, 0, sinHalfHFOV); + planes[LEFT].distance = 0; + + // Right plane: inward normal pointing left-forward + // Bounds: x <= tanHalfHFOV * z (to the left of right edge) + planes[RIGHT].normal = new Point3D(-cosHalfHFOV, 0, sinHalfHFOV); + planes[RIGHT].distance = 0; + + // Top plane: inward normal pointing down-forward (Y-down system, top is smaller Y) + // Bounds: y <= tanHalfVFOV * z (below top edge, smaller Y) + planes[TOP].normal = new Point3D(0, -cosHalfVFOV, sinHalfVFOV); + planes[TOP].distance = 0; + + // Bottom plane: inward normal pointing up-forward (larger Y is below) + // Bounds: y >= -tanHalfVFOV * z (above bottom edge, larger Y) + planes[BOTTOM].normal = new Point3D(0, cosHalfVFOV, sinHalfVFOV); + planes[BOTTOM].distance = 0; + + // Near plane: inward normal pointing forward (+Z) + // Bounds: z >= nearDistance (in front of near plane) + planes[NEAR].normal = new Point3D(0, 0, 1); + planes[NEAR].distance = nearDistance; + + // Far plane: inward normal pointing backward (-Z) + // Bounds: z <= farDistance (behind far plane) + planes[FAR].normal = new Point3D(0, 0, -1); + planes[FAR].distance = -farDistance; + } + + /** + * Tests whether an axis-aligned bounding box intersects the frustum. + * + *

This is a conservative test: returns {@code true} if the box is + * potentially visible (inside or partially inside the frustum), and + * {@code false} only if the box is completely outside all frustum planes.

+ * + *

Optimized algorithm:

+ *

For each plane, we test only the AABB corner most aligned with the + * plane normal (the "P-vertex"). If this corner is behind the plane, + * the entire AABB must be outside the frustum.

+ * + * @param box the axis-aligned bounding box to test (in view space coordinates) + * @return {@code true} if the box intersects or is inside the frustum, + * {@code false} if completely outside + */ + public boolean intersectsAABB(final Box box) { + // Get box min/max for each axis + final double minX = box.getMinX(); + final double maxX = box.getMaxX(); + final double minY = box.getMinY(); + final double maxY = box.getMaxY(); + final double minZ = box.getMinZ(); + final double maxZ = box.getMaxZ(); + + for (int i = 0; i < 6; i++) { + final Plane plane = planes[i]; + final Point3D n = plane.normal; + final double d = plane.distance; + + // Find the P-vertex: the corner most aligned with the plane normal + // If normal component is positive, use max; if negative, use min + final double px = (n.x > 0) ? maxX : minX; + final double py = (n.y > 0) ? maxY : minY; + final double pz = (n.z > 0) ? maxZ : minZ; + + // Test if P-vertex is outside the frustum (behind the plane) + // For inward-pointing normals: inside = dot(N,P) >= distance + // So outside = dot(N,P) < distance + if (n.x * px + n.y * py + n.z * pz < d) { + return false; // AABB entirely outside this plane + } + } + + return true; // AABB intersects or inside all planes + } + + /** + * Returns the near clipping plane distance. + * + * @return the near distance in world units + */ + public double getNearDistance() { + return nearDistance; + } + + /** + * Returns the far clipping plane distance. + * + * @return the far distance in world units + */ + public double getFarDistance() { + return farDistance; + } + + /** + * Sets the near and far clipping distances. + * + * @param near the near plane distance (objects closer are culled) + * @param far the far plane distance (objects farther are culled) + */ + public void setClipDistances(final double near, final double far) { + this.nearDistance = near; + this.farDistance = far; + } + + /** + * Returns a specific frustum plane for debugging or advanced usage. + * + * @param planeIndex one of LEFT, RIGHT, TOP, BOTTOM, NEAR, FAR + * @return the plane at the specified index + */ + public Plane getPlane(final int planeIndex) { + return planes[planeIndex]; + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Plane.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Plane.java new file mode 100644 index 0000000..1d5c289 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Plane.java @@ -0,0 +1,227 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.geometry; + +import eu.svjatoslav.aukio.e3d.math.Vertex; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents an infinite plane in 3D space using the Hesse normal form. + * + *

Planes are fundamental to BSP (Binary Space Partitioning) tree operations + * in CSG. They divide 3D space into two half-spaces.

+ * + * @see SolidPolygon polygons that reference their containing plane + * @see BspTree BSP trees that use planes for spatial partitioning + */ +public class Plane { + + /** + * Epsilon value used for floating-point comparisons in BSP operations. + * Smaller values provide higher precision but may cause issues with + * near-coplanar polygons. 1e-5 is a good balance for most 3D geometry. + */ + public static final double EPSILON = 1e-12; + + /** + * The unit normal vector perpendicular to the plane surface. + */ + public Point3D normal; + + /** + * The signed distance from the origin to the plane along the normal. + */ + public double distance; + + /** + * Creates a plane with the given normal and distance. + * + * @param normal the unit normal vector + * @param distance the signed distance from origin to the plane + */ + public Plane(final Point3D normal, final double distance) { + this.normal = normal; + this.distance = distance; + } + + /** + * Computes the unit normal vector for a triangle defined by three points. + * + *

Zero-allocation method: fills the result point instead of creating a new one. + * This is the shared implementation used by both {@link #fromPoints} and + * {@link SolidPolygon} for shading calculations.

+ * + *

The normal is computed as the cross product of two edge vectors (b-a and c-a), + * then normalized to unit length.

+ * + * @param a first point (base point for edge vectors) + * @param b second point + * @param c third point + * @param result Point3D to receive the unit normal vector (modified in place) + * @return true if normal computed successfully, false if points are collinear + * (cross product magnitude less than EPSILON) + */ + public static boolean computeNormal(final Point3D a, final Point3D b, + final Point3D c, final Point3D result) { + // Edge vectors from a to b and a to c + final double ax = b.x - a.x; + final double ay = b.y - a.y; + final double az = b.z - a.z; + + final double bx = c.x - a.x; + final double by = c.y - a.y; + final double bz = c.z - a.z; + + // Cross product: (edge1 × edge2) + double nx = ay * bz - az * by; + double ny = az * bx - ax * bz; + double nz = ax * by - ay * bx; + + // Normalize + final double length = Math.sqrt(nx * nx + ny * ny + nz * nz); + if (length < EPSILON) { + result.x = result.y = result.z = 0; + return false; + } + + result.x = nx / length; + result.y = ny / length; + result.z = nz / length; + return true; + } + + /** + * Creates a plane from three non-collinear points. + * + *

Uses {@link #computeNormal} for the normal calculation, then computes + * the signed distance from origin using the dot product.

+ * + * @param a the first point on the plane + * @param b the second point on the plane + * @param c the third point on the plane + * @return a new Plane passing through the three points + * @throws ArithmeticException if the points are collinear (cannot define a plane) + */ + public static Plane fromPoints(final Point3D a, final Point3D b, final Point3D c) { + final Point3D n = new Point3D(); + if (!computeNormal(a, b, c, n)) { + throw new ArithmeticException( + "Cannot create plane from collinear points: cross product is zero"); + } + return new Plane(n, n.dot(a)); + } + + /** + * Creates a deep clone of this plane. + * + * @return a new Plane with the same normal and distance + */ + public Plane clone() { + return new Plane(new Point3D(normal.x, normal.y, normal.z), distance); + } + + /** + * Flips the plane orientation by negating the normal and distance. + */ + public void flip() { + normal = normal.withNegated(); + distance = -distance; + } + + /** + * Splits a polygon by this plane, classifying and potentially dividing it. + * + * @param polygon the polygon to classify and potentially split + * @param coplanarFront list to receive coplanar polygons with same-facing normals + * @param coplanarBack list to receive coplanar polygons with opposite-facing normals + * @param front list to receive polygons in the front half-space + * @param back list to receive polygons in the back half-space + */ + public void splitPolygon(final SolidPolygon polygon, + final List coplanarFront, + final List coplanarBack, + final List front, + final List back) { + + PolygonType polygonType = PolygonType.COPLANAR; + final int vertexCount = polygon.getVertexCount(); + final PolygonType[] types = new PolygonType[vertexCount]; + + for (int i = 0; i < vertexCount; i++) { + final Vertex v = polygon.vertices.get(i); + final double t = normal.dot(v.coordinate) - distance; + final PolygonType type = (t < -EPSILON) ? PolygonType.BACK + : (t > EPSILON) ? PolygonType.FRONT : PolygonType.COPLANAR; + polygonType = polygonType.combine(type); + types[i] = type; + } + + switch (polygonType) { + case COPLANAR: + ((normal.dot(polygon.getPlane().normal) > 0) ? coplanarFront : coplanarBack).add(polygon); + break; + + case FRONT: + front.add(polygon); + break; + + case BACK: + back.add(polygon); + break; + + case SPANNING: + // Split spanning polygon by clipping each edge against the plane. + // Vertices on each side go to their respective lists. + // Edges crossing the plane create intersection vertices added to both lists. + final List frontVertices = new ArrayList<>(); + final List backVertices = new ArrayList<>(); + + for (int i = 0; i < vertexCount; i++) { + final int nextIndex = (i + 1) % vertexCount; + final PolygonType currentType = types[i]; + final PolygonType nextType = types[nextIndex]; + final Vertex currentVertex = polygon.vertices.get(i); + final Vertex nextVertex = polygon.vertices.get(nextIndex); + + // Add current vertex to the polygon on its side of the plane + if (currentType.isFront()) { + frontVertices.add(currentVertex.clone()); + } + if (currentType.isBack()) { + backVertices.add(currentVertex.clone()); + } + + // If edge crosses the plane, create intersection vertex for both polygons + if (currentType != nextType + && currentType != PolygonType.COPLANAR + && nextType != PolygonType.COPLANAR) { + // Calculate interpolation parameter t (0 = current, 1 = next) + // t represents where along the edge the plane intersection occurs + final double t = (distance - normal.dot(currentVertex.coordinate)) + / normal.dot(nextVertex.coordinate.withSubtracted(currentVertex.coordinate)); + + final Vertex intersectionVertex = currentVertex.interpolate(nextVertex, t); + frontVertices.add(intersectionVertex); + backVertices.add(intersectionVertex.clone()); + } + } + + if (frontVertices.size() >= 3) { + final SolidPolygon frontPoly = SolidPolygon.fromVertices( + frontVertices, polygon.getColor(), polygon.isShadingEnabled()); + front.add(frontPoly); + } + if (backVertices.size() >= 3) { + final SolidPolygon backPoly = SolidPolygon.fromVertices( + backVertices, polygon.getColor(), polygon.isShadingEnabled()); + back.add(backPoly); + } + break; + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Point2D.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Point2D.java new file mode 100755 index 0000000..7dc2004 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Point2D.java @@ -0,0 +1,313 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.geometry; + +import static java.lang.Math.sqrt; + +/** + * A mutable 2D point or vector with double-precision coordinates. + * + *

{@code Point2D} represents either a position in 2D space or a directional vector, + * with public {@code x} and {@code y} fields for direct access. It is commonly used + * for screen-space coordinates after 3D-to-2D projection.

+ * + *

All mutation methods return {@code this} for fluent chaining:

+ *
{@code
+ * Point2D p = new Point2D(10, 20)
+ *     .multiply(2.0)
+ *     .add(new Point2D(5, 5))
+ *     .negate();
+ * // p is now (-25, -45)
+ * }
+ * + *

Mutability convention:

+ *
    + *
  • Imperative verbs ({@code add}, {@code subtract}, {@code negate}, {@code multiply}, + * {@code divide}) mutate this point and return {@code this}
  • + *
  • {@code with}-prefixed methods ({@code withAdded}, {@code withSubtracted}, {@code withNegated}, + * {@code withMultiplied}, {@code withDivided}) return a new point without modifying this one
  • + *
+ * + *

Warning: This class is mutable with public fields. Clone before storing + * references that should not be shared:

+ *
{@code
+ * Point2D safeCopy = original.clone();
+ * }
+ * + * @see Point3D the 3D equivalent + */ +public class Point2D implements Cloneable { + + /** X coordinate (horizontal axis). */ + public double x; + /** Y coordinate (vertical axis, positive = down in screen space). */ + public double y; + + /** + * Creates a point at the origin (0, 0). + */ + public Point2D() { + } + + /** + * Creates a point with the specified coordinates. + * + * @param x the X coordinate + * @param y the Y coordinate + */ + public Point2D(final double x, final double y) { + this.x = x; + this.y = y; + } + + /** + * Creates a point by copying coordinates from another point. + * + * @param parent the point to copy from + */ + public Point2D(final Point2D parent) { + x = parent.x; + y = parent.y; + } + + + /** + * Adds another point to this point in place. + * This point is modified, the other point is not. + * + * @param otherPoint the point to add + * @return this point (for chaining) + * @see #withAdded(Point2D) for the non-mutating version that returns a new point + */ + public Point2D add(final Point2D otherPoint) { + x += otherPoint.x; + y += otherPoint.y; + return this; + } + + /** + * Checks if both coordinates are zero. + * + * @return {@code true} if current point coordinates are equal to zero + */ + public boolean isZero() { + return (x == 0) && (y == 0); + } + + /** + * Creates a new point by copying this point's coordinates. + * + * @return a new point with the same coordinates + */ + @Override + public Point2D clone() { + return new Point2D(this); + } + + /** + * Copies coordinates from another point into this point. + * + * @param otherPoint the point to copy coordinates from + */ + public void clone(final Point2D otherPoint) { + x = otherPoint.x; + y = otherPoint.y; + } + + /** + * Sets this point to the midpoint between two other points. + * + * @param p1 the first point + * @param p2 the second point + * @return this point (for chaining) + */ + public Point2D setToMiddle(final Point2D p1, final Point2D p2) { + x = (p1.x + p2.x) / 2d; + y = (p1.y + p2.y) / 2d; + return this; + } + + /** + * Computes the angle on the X-Y plane between this point and another point. + * + * @param anotherPoint the other point + * @return the angle in radians + */ + public double getAngleXY(final Point2D anotherPoint) { + return Math.atan2(x - anotherPoint.x, y - anotherPoint.y); + } + + /** + * Computes the Euclidean distance from this point to another point. + * + * @param anotherPoint the point to compute distance to + * @return the distance between the two points + */ + public double getDistanceTo(final Point2D anotherPoint) { + final double xDiff = x - anotherPoint.x; + final double yDiff = y - anotherPoint.y; + + return sqrt(((xDiff * xDiff) + (yDiff * yDiff))); + } + + /** + * Computes the length of this vector (magnitude). + * + * @return the vector length + */ + public double getVectorLength() { + return sqrt(((x * x) + (y * y))); + } + + /** + * Negates this point's coordinates in place. + * This point is modified. + * + * @return this point (for chaining) + * @see #withNegated() for the non-mutating version that returns a new point + */ + public Point2D negate() { + x = -x; + y = -y; + return this; + } + + /** + * Rounds this point's coordinates to integer values. + */ + public void roundToInteger() { + x = (int) x; + y = (int) y; + } + + /** + * Subtracts another point from this point in place. + * This point is modified, the other point is not. + * + * @param otherPoint the point to subtract + * @return this point (for chaining) + * @see #withSubtracted(Point2D) for the non-mutating version that returns a new point + */ + public Point2D subtract(final Point2D otherPoint) { + x -= otherPoint.x; + y -= otherPoint.y; + return this; + } + + /** + * Multiplies both coordinates by a factor. + * This point is modified. + * + * @param factor the multiplier + * @return this point (for chaining) + * @see #withMultiplied(double) for the non-mutating version that returns a new point + */ + public Point2D multiply(final double factor) { + x *= factor; + y *= factor; + return this; + } + + /** + * Divides both coordinates by a factor. + * This point is modified. + * + * @param factor the divisor + * @return this point (for chaining) + * @see #withDivided(double) for the non-mutating version that returns a new point + */ + public Point2D divide(final double factor) { + x /= factor; + y /= factor; + return this; + } + + /** + * Converts this 2D point to a 3D point with z = 0. + * + * @return a new 3D point with the same x, y and z = 0 + */ + public Point3D to3D() { + return new Point3D(x, y, 0); + } + + /** + * Resets this point's coordinates to (0, 0). + * + * @return this point (for chaining) + */ + public Point2D zero() { + x = 0; + y = 0; + return this; + } + + @Override + public String toString() { + return "Point2D{" + + "x=" + x + + ", y=" + y + + '}'; + } + + /** + * Returns a new point that is the sum of this point and another. + * This point is not modified. + * + * @param other the point to add + * @return a new Point2D representing the sum + * @see #add(Point2D) for the mutating version + */ + public Point2D withAdded(final Point2D other) { + return new Point2D(x + other.x, y + other.y); + } + + /** + * Returns a new point that is this point minus another. + * This point is not modified. + * + * @param other the point to subtract + * @return a new Point2D representing the difference + * @see #subtract(Point2D) for the mutating version + */ + public Point2D withSubtracted(final Point2D other) { + return new Point2D(x - other.x, y - other.y); + } + + /** + * Returns a new point with negated coordinates. + * This point is not modified. + * + * @return a new Point2D with negated coordinates + * @see #negate() for the mutating version + */ + public Point2D withNegated() { + return new Point2D(-x, -y); + } + + /** + * Returns a new point with coordinates multiplied by a factor. + * This point is not modified. + * + * @param factor the multiplier + * @return a new Point2D with multiplied coordinates + * @see #multiply(double) for the mutating version + */ + public Point2D withMultiplied(final double factor) { + return new Point2D(x * factor, y * factor); + } + + /** + * Returns a new point with coordinates divided by a factor. + * This point is not modified. + * + * @param factor the divisor + * @return a new Point2D with divided coordinates + * @see #divide(double) for the mutating version + */ + public Point2D withDivided(final double factor) { + return new Point2D(x / factor, y / factor); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Point3D.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Point3D.java new file mode 100755 index 0000000..91f7aa9 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Point3D.java @@ -0,0 +1,586 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.geometry; + +import eu.svjatoslav.aukio.e3d.renderer.octree.IntegerPoint; + +import static java.lang.Math.*; + +/** + * A mutable 3D point or vector with double-precision coordinates. + * + *

{@code Point3D} is the fundamental coordinate type used throughout the Aukio 3D engine. + * It represents either a position in 3D space or a directional vector, with public + * {@code x}, {@code y}, {@code z} fields for direct access.

+ * + *

All mutation methods return {@code this} for fluent chaining:

+ *
{@code
+ * Point3D p = new Point3D(10, 20, 30)
+ *     .multiply(2.0)
+ *     .translateX(5)
+ *     .add(new Point3D(1, 1, 1));
+ * // p is now (25, 41, 61)
+ * }
+ * + *

Common operations:

+ *
{@code
+ * // Create points
+ * Point3D origin = Point3D.origin();          // (0, 0, 0)
+ * Point3D pos = Point3D.point(100, 200, 300);
+ * Point3D copy = new Point3D(pos);            // clone
+ *
+ * // Measure distance
+ * double dist = pos.getDistanceTo(origin);
+ *
+ * // Rotation
+ * pos.rotate(origin, Math.PI / 4, 0);  // rotate 45 degrees on XZ plane
+ *
+ * // Scale
+ * pos.multiply(2.0);   // double all coordinates
+ * pos.divide(2.0);     // halve all coordinates
+ * }
+ * + *

Mutability convention:

+ *
    + *
  • Imperative verbs ({@code add}, {@code subtract}, {@code negate}, {@code multiply}, + * {@code divide}) mutate this point and return {@code this}
  • + *
  • {@code with}-prefixed methods ({@code withAdded}, {@code withSubtracted}, {@code withNegated}, + * {@code withMultiplied}, {@code withDivided}) return a new point without modifying this one
  • + *
+ * + *

Warning: This class is mutable with public fields. Clone before storing + * references that should not be shared:

+ *
{@code
+ * Point3D safeCopy = original.clone();
+ * }
+ * + * @see Point2D the 2D equivalent + * @see eu.svjatoslav.aukio.e3d.math.Vertex wraps a Point3D with transform support + */ +public class Point3D implements Cloneable { + + /** X coordinate (horizontal axis). */ + public double x; + /** Y coordinate (vertical axis, positive = down in screen space). */ + public double y; + /** Z coordinate (depth axis, positive = into the screen / away from viewer). */ + public double z; + + /** + * Creates a point at the origin (0, 0, 0). + */ + public Point3D() { + } + + /** + * Creates a point with the specified double-precision coordinates. + * + * @param x the X coordinate + * @param y the Y coordinate + * @param z the Z coordinate + */ + public Point3D(final double x, final double y, final double z) { + this.x = x; + this.y = y; + this.z = z; + } + + /** + * Creates a point with the specified float coordinates (widened to double). + * + * @param x the X coordinate + * @param y the Y coordinate + * @param z the Z coordinate + */ + public Point3D(final float x, final float y, final float z) { + this.x = x; + this.y = y; + this.z = z; + } + + /** + * Creates a point with the specified integer coordinates (widened to double). + * + * @param x the X coordinate + * @param y the Y coordinate + * @param z the Z coordinate + */ + public Point3D(final int x, final int y, final int z) { + this.x = x; + this.y = y; + this.z = z; + } + + /** + * Creates a point from an {@link IntegerPoint} (used by octree voxel coordinates). + * + * @param point the integer point to convert + */ + public Point3D(IntegerPoint point) { + this.x = point.x; + this.y = point.y; + this.z = point.z; + } + + + /** + * Creates a new point by cloning coordinates from the parent point. + * + * @param parent the point to copy coordinates from + */ + public Point3D(final Point3D parent) { + x = parent.x; + y = parent.y; + z = parent.z; + } + + /** + * Returns a new point at the origin (0, 0, 0). + * + * @return a new Point3D at the origin + */ + public static Point3D origin() { + return new Point3D(); + } + + /** + * Returns a new point with the specified coordinates. + * + * @param x the X coordinate + * @param y the Y coordinate + * @param z the Z coordinate + * @return a new Point3D with the given coordinates + */ + public static Point3D point(final double x, final double y, final double z) { + return new Point3D(x, y, z); + } + + /** + * Adds another point to this point in place. + * This point is modified, the other point is not. + * + * @param otherPoint the point to add + * @return this point (for chaining) + * @see #withAdded(Point3D) for the non-mutating version that returns a new point + */ + public Point3D add(final Point3D otherPoint) { + x += otherPoint.x; + y += otherPoint.y; + z += otherPoint.z; + return this; + } + + /** + * Adds coordinates of current point to one or more other points. + * The current point's coordinates are added to each target point. + * + * @param otherPoints the points to add this point's coordinates to + * @return this point (for chaining) + */ + public Point3D addTo(final Point3D... otherPoints) { + for (final Point3D otherPoint : otherPoints) otherPoint.add(this); + return this; + } + + /** + * Create new point by cloning position of current point. + * + * @return newly created clone. + */ + public Point3D clone() { + return new Point3D(this); + } + + /** + * Copies coordinates from another point into this point. + * + * @param otherPoint the point to copy coordinates from + * @return this point (for chaining) + */ + public Point3D clone(final Point3D otherPoint) { + x = otherPoint.x; + y = otherPoint.y; + z = otherPoint.z; + return this; + } + + /** + * Set current point coordinates to the middle point between two other points. + * + * @param p1 first point. + * @param p2 second point. + * @return current point. + */ + public Point3D computeMiddlePoint(final Point3D p1, final Point3D p2) { + x = (p1.x + p2.x) / 2d; + y = (p1.y + p2.y) / 2d; + z = (p1.z + p2.z) / 2d; + return this; + } + + /** + * Checks if all coordinates are zero. + * + * @return {@code true} if current point coordinates are equal to zero + */ + public boolean isZero() { + return (x == 0) && (y == 0) && (z == 0); + } + + /** + * Computes the angle on the X-Z plane between this point and another point. + * + * @param anotherPoint the other point + * @return the angle in radians + */ + public double getAngleXZ(final Point3D anotherPoint) { + return Math.atan2(x - anotherPoint.x, z - anotherPoint.z); + } + + /** + * Computes the angle on the Y-Z plane between this point and another point. + * + * @param anotherPoint the other point + * @return the angle in radians + */ + public double getAngleYZ(final Point3D anotherPoint) { + return Math.atan2(y - anotherPoint.y, z - anotherPoint.z); + } + + /** + * Computes the angle on the X-Y plane between this point and another point. + * + * @param anotherPoint the other point + * @return the angle in radians + */ + public double getAngleXY(final Point3D anotherPoint) { + return Math.atan2(x - anotherPoint.x, y - anotherPoint.y); + } + + /** + * Compute distance to another point. + * + * @param anotherPoint point to compute distance to. + * @return distance to another point. + */ + public double getDistanceTo(final Point3D anotherPoint) { + final double xDelta = x - anotherPoint.x; + final double yDelta = y - anotherPoint.y; + final double zDelta = z - anotherPoint.z; + + return sqrt(((xDelta * xDelta) + (yDelta * yDelta) + (zDelta * zDelta))); + } + + /** + * Computes the length (magnitude) of this vector. + * + * @return the vector length + */ + public double getVectorLength() { + return sqrt(((x * x) + (y * y) + (z * z))); + } + + /** + * Negates this point's coordinates in place. + * This point is modified. + * + * @return this point (for chaining) + * @see #withNegated() for the non-mutating version that returns a new point + */ + public Point3D negate() { + x = -x; + y = -y; + z = -z; + return this; + } + + /** + * Rotates this point around a center point by the given XZ and YZ angles. + *

+ * See also: Let's remove Quaternions from every 3D Engine + * + * @param center the center point to rotate around + * @param angleXZ the angle in the XZ plane (yaw) in radians + * @param angleYZ the angle in the YZ plane (pitch) in radians + * @return this point (for chaining) + */ + public Point3D rotate(final Point3D center, final double angleXZ, + final double angleYZ) { + final double s1 = sin(angleXZ); + final double c1 = cos(angleXZ); + + final double s2 = sin(angleYZ); + final double c2 = cos(angleYZ); + + x -= center.x; + y -= center.y; + z -= center.z; + + final double y1 = (z * s2) + (y * c2); + final double z1 = (z * c2) - (y * s2); + + final double x1 = (z1 * s1) + (x * c1); + final double z2 = (z1 * c1) - (x * s1); + + x = x1 + center.x; + y = y1 + center.y; + z = z2 + center.z; + + return this; + } + + /** + * Rotate current point around the origin by the given angles. + * + * @param angleXZ angle around the XZ plane (yaw), in radians + * @param angleYZ angle around the YZ plane (pitch), in radians + * @return this point (mutated) + */ + public Point3D rotate(final double angleXZ, final double angleYZ) { + return rotate(new Point3D(0, 0, 0), angleXZ, angleYZ); + } + + /** + * Round current point coordinates to integer values. + */ + public void roundToInteger() { + x = (int) x; + y = (int) y; + z = (int) z; + } + + /** + * Divides all coordinates by a factor. + * This point is modified. + * + * @param factor the divisor + * @return this point (for chaining) + * @see #withDivided(double) for the non-mutating version that returns a new point + */ + public Point3D divide(final double factor) { + x /= factor; + y /= factor; + z /= factor; + return this; + } + + /** + * Multiplies all coordinates by a factor. + * This point is modified. + * + * @param factor the multiplier + * @return this point (for chaining) + * @see #withMultiplied(double) for the non-mutating version that returns a new point + */ + public Point3D multiply(final double factor) { + x *= factor; + y *= factor; + z *= factor; + return this; + } + + /** + * Set current point coordinates to given values. + * + * @param x X coordinate. + * @param y Y coordinate. + * @param z Z coordinate. + */ + public void setValues(final double x, final double y, final double z) { + this.x = x; + this.y = y; + this.z = z; + } + + /** + * Subtracts another point from this point in place. + * This point is modified, the other point is not. + * + * @param otherPoint the point to subtract + * @return this point (for chaining) + * @see #withSubtracted(Point3D) for the non-mutating version that returns a new point + */ + public Point3D subtract(final Point3D otherPoint) { + x -= otherPoint.x; + y -= otherPoint.y; + z -= otherPoint.z; + return this; + } + + @Override + public String toString() { + return "x:" + x + " y:" + y + " z:" + z; + } + + /** + * Translates this point along the X axis. + * + * @param xIncrement the amount to add to the X coordinate + * @return this point (for chaining) + */ + public Point3D translateX(final double xIncrement) { + x += xIncrement; + return this; + } + + /** + * Translates this point along the Y axis. + * + * @param yIncrement the amount to add to the Y coordinate + * @return this point (for chaining) + */ + public Point3D translateY(final double yIncrement) { + y += yIncrement; + return this; + } + + /** + * Translates this point along the Z axis. + * + * @param zIncrement the amount to add to the Z coordinate + * @return this point (for chaining) + */ + public Point3D translateZ(final double zIncrement) { + z += zIncrement; + return this; + } + + /** + * Here we assume that Z coordinate is distance to the viewer. + * If Z is positive, then point is in front of the viewer, and therefore it is visible. + * + * @return point visibility status. + */ + public boolean isVisible() { + return z > 0; + } + + /** + * Resets point coordinates to zero along all axes. + * + * @return current point. + */ + public Point3D zero() { + x = 0; + y = 0; + z = 0; + return this; + } + + /** + * Computes the dot product of this vector with another. + * + * @param other the other vector + * @return the dot product (scalar) + */ + public double dot(final Point3D other) { + return x * other.x + y * other.y + z * other.z; + } + + /** + * Computes the cross-product of this vector with another. + * Returns a new vector perpendicular to both input vectors. + * + * @param other the other vector + * @return a new Point3D representing the cross-product + */ + public Point3D cross(final Point3D other) { + return new Point3D( + y * other.z - z * other.y, + z * other.x - x * other.z, + x * other.y - y * other.x + ); + } + + /** + * Returns a new point that is the sum of this point and another. + * This point is not modified. + * + * @param other the point to add + * @return a new Point3D representing the sum + * @see #add(Point3D) for the mutating version + */ + public Point3D withAdded(final Point3D other) { + return new Point3D(x + other.x, y + other.y, z + other.z); + } + + /** + * Returns a new point that is this point minus another. + * This point is not modified. + * + * @param other the point to subtract + * @return a new Point3D representing the difference + * @see #subtract(Point3D) for the mutating version + */ + public Point3D withSubtracted(final Point3D other) { + return new Point3D(x - other.x, y - other.y, z - other.z); + } + + /** + * Returns a new point with negated coordinates. + * This point is not modified. + * + * @return a new Point3D with negated coordinates + * @see #negate() for the mutating version + */ + public Point3D withNegated() { + return new Point3D(-x, -y, -z); + } + + /** + * Returns a new unit vector (normalized) in the same direction. + * This point is not modified. + * + * @return a new Point3D with unit length + */ + public Point3D unit() { + final double len = getVectorLength(); + if (len == 0) { + return new Point3D(0, 0, 0); + } + return new Point3D(x / len, y / len, z / len); + } + + /** + * Returns a new point that is a linear interpolation between this point and another. + * When t=0, returns this point. When t=1, returns the other point. + * + * @param other the other point + * @param t the interpolation parameter (0 to 1) + * @return a new Point3D representing the interpolated position + */ + public Point3D interpolate(final Point3D other, final double t) { + return new Point3D( + x + (other.x - x) * t, + y + (other.y - y) * t, + z + (other.z - z) * t + ); + } + + /** + * Returns a new point with coordinates multiplied by a factor. + * This point is not modified. + * + * @param factor the multiplier + * @return a new Point3D with multiplied coordinates + * @see #multiply(double) for the mutating version + */ + public Point3D withMultiplied(final double factor) { + return new Point3D(x * factor, y * factor, z * factor); + } + + /** + * Returns a new point with coordinates divided by a factor. + * This point is not modified. + * + * @param factor the divisor + * @return a new Point3D with divided coordinates + * @see #divide(double) for the mutating version + */ + public Point3D withDivided(final double factor) { + return new Point3D(x / factor, y / factor, z / factor); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Polygon.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Polygon.java new file mode 100644 index 0000000..6c1b994 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Polygon.java @@ -0,0 +1,83 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.geometry; + +/** + * Utility class for polygon operations, primarily point-in-polygon testing. + * + *

Provides static methods for geometric computations on triangles and other polygons.

+ * + * @see Point2D + */ +public class Polygon { + + /** + * Creates a new Polygon utility instance. + */ + public Polygon() { + } + + + /** + * Checks if a point is on the right side of a directed line segment. + * Used internally for ray-casting in point-in-polygon tests. + * + * @param point the point to test + * @param lineP1 the start point of the line segment + * @param lineP2 the end point of the line segment + * @return {@code true} if the point is on the right side of the line + */ + private static boolean intersectsLine(final Point2D point, Point2D lineP1, + Point2D lineP2) { + + // Sort line points by y coordinate. + if (lineP1.y > lineP2.y) { + final Point2D tmp = lineP1; + lineP1 = lineP2; + lineP2 = tmp; + } + + // Check if point is within line y range. + if (point.y < lineP1.y || point.y > lineP2.y) + return false; + + // Check if point is on the line. + final double xp = lineP2.x - lineP1.x; + final double yp = lineP2.y - lineP1.y; + + final double crossX = lineP1.x + ((xp * (point.y - lineP1.y)) / yp); + + return point.x >= crossX; + } + + /** + * Tests whether a point lies inside a triangle using the ray-casting algorithm. + * + *

Casts a horizontal ray from the test point and counts intersections + * with the triangle edges. If the number of intersections is odd, the point is inside.

+ * + * @param point the point to test + * @param p1 the first vertex of the triangle + * @param p2 the second vertex of the triangle + * @param p3 the third vertex of the triangle + * @return {@code true} if the point is inside the triangle + */ + public static boolean pointWithinPolygon(final Point2D point, Point2D p1, Point2D p2, Point2D p3) { + + int intersectionCount = 0; + + if (intersectsLine(point, p1, p2)) + intersectionCount++; + + if (intersectsLine(point, p2, p3)) + intersectionCount++; + + if (intersectsLine(point, p3, p1)) + intersectionCount++; + + return intersectionCount == 1; + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/PolygonType.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/PolygonType.java new file mode 100644 index 0000000..effc9d4 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/PolygonType.java @@ -0,0 +1,56 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.geometry; + +/** + * Classification of a polygon's position relative to a plane. + * Used in BSP tree operations to determine how polygons should be split. + */ +public enum PolygonType { + /** Polygon lies on the plane. */ + COPLANAR, + /** Polygon is entirely in front of the plane. */ + FRONT, + /** Polygon is entirely behind the plane. */ + BACK, + /** Polygon straddles the plane (vertices on both sides). */ + SPANNING; + + /** + * Combines this type with another to compute the aggregate classification. + * When vertices are on both sides of a plane, the result is SPANNING. + * + * @param other the other polygon type to combine with + * @return the combined classification + */ + public PolygonType combine(final PolygonType other) { + if (this == other || other == COPLANAR) { + return this; + } + if (this == COPLANAR) { + return other; + } + // FRONT + BACK = SPANNING + return SPANNING; + } + + /** + * Checks if this type represents a vertex in front of the plane. + * + * @return true if FRONT or COPLANAR (treated as front for classification) + */ + public boolean isFront() { + return this == FRONT || this == COPLANAR; + } + + /** + * Checks if this type represents a vertex behind the plane. + * + * @return true if BACK or COPLANAR (treated as back for classification) + */ + public boolean isBack() { + return this == BACK || this == COPLANAR; + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Rectangle.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Rectangle.java new file mode 100644 index 0000000..41b4195 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Rectangle.java @@ -0,0 +1,83 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.geometry; + +import static java.lang.Math.abs; +import static java.lang.Math.min; + +/** + * A 2D axis-aligned rectangle defined by two corner points. + * + *

The rectangle is defined by two points ({@link #p1} and {@link #p2}) that represent + * opposite corners. The rectangle does not enforce ordering of these points.

+ * + * @see Point2D + * @see Box the 3D equivalent + */ +public class Rectangle { + + /** + * The corner points of the rectangle (opposite corners). + */ + public Point2D p1, p2; + + /** + * Creates a square rectangle centered at the origin with the specified size. + * + * @param size the width and height of the square + */ + public Rectangle(final double size) { + p2 = new Point2D(size / 2, size / 2); + p1 = p2.clone().negate(); + } + + /** + * Creates a rectangle with the specified corner points. + * + * @param p1 the first corner point + * @param p2 the second corner point (opposite corner) + */ + public Rectangle(final Point2D p1, final Point2D p2) { + this.p1 = p1; + this.p2 = p2; + } + + /** + * Returns the height of the rectangle (distance along the Y-axis). + * + * @return the height (always positive) + */ + public double getHeight() { + return abs(p1.y - p2.y); + } + + /** + * Returns the leftmost X coordinate of the rectangle. + * + * @return the minimum X value + */ + public double getLowerX() { + return min(p1.x, p2.x); + } + + /** + * Returns the topmost Y coordinate of the rectangle. + * + * @return the minimum Y value + */ + public double getLowerY() { + return min(p1.y, p2.y); + } + + /** + * Returns the width of the rectangle (distance along the X-axis). + * + * @return the width (always positive) + */ + public double getWidth() { + return abs(p1.x - p2.x); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/package-info.java new file mode 100644 index 0000000..a4db80e --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/package-info.java @@ -0,0 +1,7 @@ +/** + * Provides basic geometry classes for 2D and 3D coordinates and shapes. + * + * @see eu.svjatoslav.aukio.e3d.geometry.Point2D + * @see eu.svjatoslav.aukio.e3d.geometry.Point3D + */ +package eu.svjatoslav.aukio.e3d.geometry; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/BugReport.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/BugReport.java new file mode 100644 index 0000000..e684fe4 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/BugReport.java @@ -0,0 +1,232 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +import eu.svjatoslav.aukio.e3d.diag.EngineConfig; +import eu.svjatoslav.aukio.e3d.diag.PersistentLog; +import eu.svjatoslav.aukio.e3d.diag.Telemetry; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; + +import javax.imageio.ImageIO; +import java.awt.GraphicsDevice; +import java.awt.GraphicsEnvironment; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +/** + * Writes a self-contained bug report directory the user can point a + * developer (or an AI assistant) at. + * + *

A new directory {@code bugreport-} is created under the + * configured {@code bugreport.dir} containing:

+ *
    + *
  • {@code description.txt} — what the user wrote in the popup
  • + *
  • {@code info.txt} — camera pose, view/screen resolution, heap, + * telemetry snapshot, java/os versions, effective config
  • + *
  • {@code screenshot.png} — a copy of the last rendered frame
  • + *
  • {@code aukio.log} / {@code aukio.log.1} — the persistent logs, + * including output from a session that froze before the report + * could be made
  • + *
  • {@code histogram.txt} — live-object histogram from + * {@code jcmd GC.class_histogram} (the OOM smoking gun; + * skipped when jcmd is unavailable)
  • + *
  • {@code threads.txt} — full thread dump (hang diagnosis)
  • + *
+ */ +public final class BugReport { + + private static final DateTimeFormatter DIR_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + + private BugReport() { + } + + /** + * Creates a bug report for the given view. + * + * @param viewPanel the view whose camera and last frame to capture + * (may be null — screenshot/camera are then + * skipped) + * @param description free-form user description of the problem + * @return the created report directory + */ + public static File create(final ViewPanel viewPanel, + final String description) throws IOException { + final File root = EngineConfig.getBugReportDir(); + File dir = new File(root, + "bugreport-" + LocalDateTime.now().format(DIR_FORMATTER)); + int suffix = 1; + while (dir.exists()) { + dir = new File(dir.getPath() + "-" + (++suffix)); + } + Files.createDirectories(dir.toPath()); + + Files.writeString(new File(dir, "description.txt").toPath(), + description == null ? "" : description); + writeInfo(new File(dir, "info.txt"), viewPanel); + writeScreenshot(dir, viewPanel); + copyLogs(dir); + writeHistogram(dir); + writeThreadDump(new File(dir, "threads.txt")); + + System.out.println("[BUGREPORT] written to " + + dir.getAbsolutePath()); + return dir; + } + + private static void writeInfo(final File file, + final ViewPanel viewPanel) + throws IOException { + final Runtime rt = Runtime.getRuntime(); + final long used = rt.totalMemory() - rt.freeMemory(); + final StringBuilder sb = new StringBuilder(); + sb.append("time = ").append(LocalDateTime.now()).append('\n'); + + if (viewPanel != null) { + final Camera camera = viewPanel.getCamera(); + final Point3D pos = camera.getTransform().getTranslation(); + final double[] angles = + camera.getTransform().getRotation().toAngles(); + sb.append(String.format( + "camera = (%.2f, %.2f, %.2f, %.2f, %.2f, %.2f)" + + " # x, y, z, yaw, pitch, roll%n", + pos.x, pos.y, pos.z, + angles[0], angles[1], angles[2])); + sb.append(String.format("view.size = %dx%d%n", + viewPanel.getWidth(), viewPanel.getHeight())); + sb.append("measured.fps = ").append(String.format("%.1f", + viewPanel.getMeasuredFPS())).append('\n'); + } + + try { + final GraphicsDevice device = GraphicsEnvironment + .getLocalGraphicsEnvironment() + .getDefaultScreenDevice(); + sb.append(String.format("screen.resolution = %dx%d@%dHz%n", + device.getDisplayMode().getWidth(), + device.getDisplayMode().getHeight(), + device.getDisplayMode().getRefreshRate())); + } catch (final Throwable t) { + sb.append("screen.resolution = unavailable (") + .append(t.getMessage()).append(")\n"); + } + + sb.append(String.format("heap.used = %d MB%nheap.max = %d MB%n", + used / (1024 * 1024), rt.maxMemory() / (1024 * 1024))); + sb.append("telemetry = ").append(Telemetry.snapshotLine()) + .append('\n'); + sb.append("java.version = ") + .append(System.getProperty("java.version")).append('\n'); + sb.append("os = ").append(System.getProperty("os.name")) + .append(' ').append(System.getProperty("os.version")) + .append('\n'); + sb.append("config.ipd.cm = ").append(EngineConfig.getIpdCm()) + .append('\n'); + sb.append("config.bugreport.dir = ") + .append(EngineConfig.getBugReportDir()).append('\n'); + sb.append("config.log.dir = ") + .append(EngineConfig.getLogDir()).append('\n'); + + Files.writeString(file.toPath(), sb.toString()); + } + + private static void writeScreenshot(final File dir, + final ViewPanel viewPanel) + throws IOException { + if (viewPanel == null) + return; + final BufferedImage lastFrame = viewPanel.getLastFrameImage(); + if (lastFrame == null) { + Files.writeString(new File(dir, "screenshot-missing.txt") + .toPath(), + "no frame had been rendered yet\n"); + return; + } + // the engine reuses frame buffers (triple buffering) — copy + final BufferedImage copy = new BufferedImage(lastFrame.getWidth(), + lastFrame.getHeight(), BufferedImage.TYPE_INT_RGB); + copy.getGraphics().drawImage(lastFrame, 0, 0, null); + ImageIO.write(copy, "png", new File(dir, "screenshot.png")); + } + + private static void copyLogs(final File dir) throws IOException { + final File log = PersistentLog.getLogFile(); + if (log == null || !log.isFile()) + return; + Files.copy(log.toPath(), + new File(dir, log.getName()).toPath(), + StandardCopyOption.REPLACE_EXISTING); + final File backup = new File(log.getParentFile(), + log.getName() + ".1"); + if (backup.isFile()) { + Files.copy(backup.toPath(), + new File(dir, backup.getName()).toPath(), + StandardCopyOption.REPLACE_EXISTING); + } + } + + /** + * Live-object histogram: forces a full GC and lists instance counts + * per class — the direct answer to "what is leaking". Best effort: + * needs the JDK's jcmd on the PATH. Bounded by a timeout: jcmd + * self-attach can hang in some environments, and a bug report must + * still complete without it. + */ + private static void writeHistogram(final File dir) { + final String pid = String.valueOf(ProcessHandle.current().pid()); + final File out = new File(dir, "histogram.txt"); + try { + final Process process = new ProcessBuilder("jcmd", pid, + "GC.class_histogram").redirectErrorStream(true) + .redirectOutput(out).start(); + final boolean done = process.waitFor(30, + java.util.concurrent.TimeUnit.SECONDS); + if (done && process.exitValue() == 0) { + return; + } + if (!done) { + process.destroyForcibly(); + } + out.delete(); + Files.writeString( + new File(dir, "histogram-unavailable.txt").toPath(), + "jcmd GC.class_histogram did not finish within 30s\n"); + } catch (final Throwable t) { + try { + Files.writeString( + new File(dir, "histogram-unavailable.txt").toPath(), + "jcmd GC.class_histogram failed: " + t + "\n"); + } catch (final IOException ignored) { + } + } + } + + private static void writeThreadDump(final File file) + throws IOException { + final StringBuilder sb = new StringBuilder(); + for (final Map.Entry entry + : Thread.getAllStackTraces().entrySet()) { + final Thread thread = entry.getKey(); + sb.append('"').append(thread.getName()).append('"') + .append(thread.isDaemon() ? " daemon" : "") + .append(" state=").append(thread.getState()) + .append('\n'); + for (final StackTraceElement element : entry.getValue()) { + sb.append(" at ").append(element).append('\n'); + } + sb.append('\n'); + } + sb.append("vm = ").append(ManagementFactory.getRuntimeMXBean() + .getVmName()).append('\n'); + Files.writeString(file.toPath(), sb.toString()); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/Camera.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/Camera.java new file mode 100644 index 0000000..a212821 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/Camera.java @@ -0,0 +1,235 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.math.Matrix3x3; +import eu.svjatoslav.aukio.e3d.math.Quaternion; +import eu.svjatoslav.aukio.e3d.math.Transform; + +/** + * Represents the viewer's camera in the 3D world, with position, orientation, and movement. + * + *

The camera is the user's "eyes" in the 3D scene. It has a position (location), + * a looking direction (defined by a quaternion), and a movement system with + * velocity, acceleration, and friction for smooth camera navigation.

+ * + *

By default, the user can navigate using arrow keys (handled by + * {@link eu.svjatoslav.aukio.e3d.gui.humaninput.WorldNavigationUserInputTracker}), + * and the mouse controls the look direction (handled by + * {@link eu.svjatoslav.aukio.e3d.gui.humaninput.InputManager}).

+ * + *

Programmatic camera control:

+ *
{@code
+ * Camera camera = viewPanel.getCamera();
+ *
+ * // Set camera position
+ * camera.getTransform().setTranslation(new Point3D(0, -50, -200));
+ *
+ * // Set camera orientation using a quaternion
+ * camera.getTransform().getRotation().set(Quaternion.fromAngles(0.5, -0.3));
+ *
+ * // Copy camera state from another camera
+ * Camera snapshot = new Camera(camera);
+ * }
+ * + * @see ViewPanel#getCamera() + * @see eu.svjatoslav.aukio.e3d.gui.humaninput.WorldNavigationUserInputTracker default keyboard navigation + */ +public class Camera implements FrameListener { + + /** + * Camera movement speed limit, relative to the world. When camera coordinates are + * updated within the world, camera orientation relative to the world is + * taken into account. + */ + public static final double SPEED_LIMIT = 30; + + /** World units moved per millisecond per unit of velocity. + * Public: direct-drive controllers (SpaceMouse) reuse it to match + * the keyboard/mouse feel. */ + public static final double SPEED_MULTIPLIER = .02d; + /** + * Determines amount of friction user experiences every millisecond while moving around in space. + */ + private static final double MILLISECOND_FRICTION = 1.005; + /** + * Camera movement speed, relative to camera itself. When camera coordinates + * are updated within the world, camera orientation relative to the world is + * taken into account. + */ + private final Point3D movementVector = new Point3D(); + private final Point3D previousLocation = new Point3D(); + /** + * Camera acceleration factor for movement speed. Higher values result in faster acceleration. + */ + public double cameraAcceleration = 0.1; + /** + * The transform containing camera location and orientation. + */ + private final Transform transform; + + /** + * Creates a camera at the world origin with no rotation. + */ + public Camera() { + transform = new Transform(); + } + + /** + * Creates a copy of an existing camera, cloning its position and orientation. + * + * @param sourceView the camera to copy + */ + public Camera(final Camera sourceView) { + transform = sourceView.getTransform().clone(); + } + + /** + * Creates a camera with the specified transform (position and orientation). + * + * @param transform the initial transform defining position and rotation + */ + public Camera(final Transform transform){ + this.transform = transform; + } + + @Override + public boolean onFrame(final ViewPanel viewPanel, final int millisecondsSinceLastFrame) { + + previousLocation.clone(transform.getTranslation()); + translateCameraLocationBasedOnMovementVector(millisecondsSinceLastFrame); + applyFrictionToMovement(millisecondsSinceLastFrame); + return isFrameRepaintNeeded(); + } + + private boolean isFrameRepaintNeeded() { + final double distanceMoved = transform.getTranslation().getDistanceTo(previousLocation); + return distanceMoved > 0.03; + } + + /** + * Clamps the camera's movement speed to {@link #SPEED_LIMIT}. + * Called after modifying the movement vector to prevent excessive velocity. + */ + public void enforceSpeedLimit() { + final double currentSpeed = movementVector.getVectorLength(); + + if (currentSpeed <= SPEED_LIMIT) + return; + + movementVector.divide(currentSpeed / SPEED_LIMIT); + } + + /** + * Returns the current movement velocity vector, relative to the camera's orientation. + * Modify this vector to programmatically move the camera. + * + * @return the movement vector (mutable reference) + */ + public Point3D getMovementVector() { + return movementVector; + } + + /** + * Returns the current movement speed (magnitude of the movement vector). + * + * @return the scalar speed value + */ + public double getMovementSpeed() { + return movementVector.getVectorLength(); + } + + /** + * Apply friction to camera movement vector. + * + * @param millisecondsPassedSinceLastFrame We want camera movement to be independent of framerate. + * Therefore, we take frame rendering time into account when translating + * camera between consecutive frames. + */ + private void applyFrictionToMovement(int millisecondsPassedSinceLastFrame) { + for (int i = 0; i < millisecondsPassedSinceLastFrame; i++) + applyMillisecondFrictionToUserMovementVector(); + } + + /** + * Apply friction to camera movement vector. + */ + private void applyMillisecondFrictionToUserMovementVector() { + movementVector.x /= MILLISECOND_FRICTION; + movementVector.y /= MILLISECOND_FRICTION; + movementVector.z /= MILLISECOND_FRICTION; + } + + /** + * Translate coordinates based on camera movement vector and camera orientation in the world. + * + * @param millisecondsPassedSinceLastFrame We want camera movement to be independent of framerate. + * Therefore, we take frame rendering time into account when translating + * camera between consecutive frames. + */ + private void translateCameraLocationBasedOnMovementVector(int millisecondsPassedSinceLastFrame) { + final Matrix3x3 m = transform.getRotation().toMatrix(); + + final double forwardX = m.m20; + final double forwardY = m.m21; + final double forwardZ = m.m22; + + final double rightX = m.m00; + final double rightY = m.m01; + final double rightZ = m.m02; + + final Point3D location = transform.getTranslation(); + final double ms = millisecondsPassedSinceLastFrame; + + location.x += forwardX * movementVector.z * SPEED_MULTIPLIER * ms; + location.y += forwardY * movementVector.z * SPEED_MULTIPLIER * ms; + location.z += forwardZ * movementVector.z * SPEED_MULTIPLIER * ms; + + location.x += rightX * movementVector.x * SPEED_MULTIPLIER * ms; + location.y += rightY * movementVector.x * SPEED_MULTIPLIER * ms; + location.z += rightZ * movementVector.x * SPEED_MULTIPLIER * ms; + + location.y += movementVector.y * SPEED_MULTIPLIER * ms; + } + + /** + * Returns the transform containing this camera's location and orientation. + * + * @return the transform (mutable reference) + */ + public Transform getTransform() { + return transform; + } + + /** + * Orients the camera to look at a target point in world coordinates. + * + *

Calculates the required XZ and YZ rotation angles to point the camera + * from its current position toward the target. Useful for programmatic + * camera control, cinematic sequences, and following objects.

+ * + *

Example:

+ *
{@code
+     * Camera camera = viewPanel.getCamera();
+     * camera.getTransform().setTranslation(new Point3D(100, -50, -200));
+     * camera.lookAt(new Point3D(0, 0, 0));  // Point camera at origin
+     * }
+ * + * @param target the world-space point to look at + */ + public void lookAt(final Point3D target) { + final Point3D pos = transform.getTranslation(); + final double dx = target.x - pos.x; + final double dy = target.y - pos.y; + final double dz = target.z - pos.z; + + final double angleXZ = -Math.atan2(dx, dz); + final double horizontalDist = Math.sqrt(dx * dx + dz * dz); + final double angleYZ = -Math.atan2(dy, horizontalDist); + + transform.getRotation().set(Quaternion.fromAngles(angleXZ, angleYZ)); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/CullingStatistics.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/CullingStatistics.java new file mode 100644 index 0000000..3b112c5 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/CullingStatistics.java @@ -0,0 +1,64 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Statistics for frustum culling, tracking composite-level culling efficiency. + * + *

Updated each frame during the rendering pipeline:

+ *
    + *
  • {@link #totalComposites} - incremented before each composite's frustum test
  • + *
  • {@link #culledComposites} - incremented when a composite fails the frustum test
  • + *
+ * + *

Thread safety: counters are {@link AtomicInteger} because the parallel + * transform phase increments them from multiple worker threads.

+ * + *

Displayed in the {@link DeveloperToolsPanel} to help developers understand + * culling efficiency and optimize scene graphs.

+ * + * @see DeveloperToolsPanel + * @see eu.svjatoslav.aukio.e3d.geometry.Frustum + */ +public class CullingStatistics { + + /** + * Total number of composite shapes tested against the frustum this frame. + * Incremented before each composite's AABB frustum test. + * Does not include the root composite (which is never frustum-tested). + */ + public final AtomicInteger totalComposites = new AtomicInteger(0); + + /** + * Number of composite shapes that were entirely outside the frustum and skipped. + * When a composite is culled, all its children (shapes and nested composites) + * are skipped without individual testing. + */ + public final AtomicInteger culledComposites = new AtomicInteger(0); + + /** + * Resets all statistics to zero. + * Called at the start of each frame before computing new statistics. + */ + public void reset() { + totalComposites.set(0); + culledComposites.set(0); + } + + /** + * Returns the percentage of composites that were culled. + * + * @return the culled percentage (0-100), or 0 if there are no composites + */ + public double getCulledPercentage() { + final int total = totalComposites.get(); + if (total == 0) { + return 0.0; + } + return 100.0 * culledComposites.get() / total; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/DebugLogBuffer.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/DebugLogBuffer.java new file mode 100644 index 0000000..5104b37 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/DebugLogBuffer.java @@ -0,0 +1,99 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +/** + * Circular buffer for debug log messages. + * + *

Captures log messages to a fixed-size circular buffer for display + * in the {@link DeveloperToolsPanel}.

+ * + *

This allows capturing early initialization logs before the user opens + * the Developer Tools panel. When the panel is opened, the buffered history + * becomes immediately visible.

+ * + * @see DeveloperToolsPanel + */ +public class DebugLogBuffer { + + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofPattern("HH:mm:ss.SSS"); + + private final String[] buffer; + private final int capacity; + private volatile int head = 0; + private volatile int count = 0; + + /** + * Creates a new DebugLogBuffer with the specified capacity. + * + * @param capacity the maximum number of log entries to retain + */ + public DebugLogBuffer(final int capacity) { + this.capacity = capacity; + this.buffer = new String[capacity]; + } + + /** + * Logs a message with a timestamp prefix. + * + * @param message the message to log + */ + public void log(final String message) { + final String timestamped = LocalDateTime.now().format(TIME_FORMATTER) + " " + message; + + synchronized (this) { + buffer[head] = timestamped; + head = (head + 1) % capacity; + if (count < capacity) { + count++; + } + } + } + + /** + * Returns all buffered log entries in chronological order. + * + * @return a list of timestamped log entries + */ + public synchronized List getEntries() { + final List entries = new ArrayList<>(count); + + if (count < capacity) { + for (int i = 0; i < count; i++) { + entries.add(buffer[i]); + } + } else { + for (int i = 0; i < capacity; i++) { + final int index = (head + i) % capacity; + entries.add(buffer[index]); + } + } + + return entries; + } + + /** + * Clears all buffered log entries. + */ + public synchronized void clear() { + head = 0; + count = 0; + } + + /** + * Returns the current number of log entries in the buffer. + * + * @return the number of entries + */ + public synchronized int size() { + return count; + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeveloperTools.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeveloperTools.java new file mode 100644 index 0000000..4813daf --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeveloperTools.java @@ -0,0 +1,46 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +/** + * Per-ViewPanel developer tools that control diagnostic features. + * + *

Each {@link ViewPanel} has its own DeveloperTools instance, allowing + * different views to have independent debug configurations.

+ * + *

Settings can be toggled at runtime via the {@link DeveloperToolsPanel} + * (opened with F12 key).

+ * + * @see ViewPanel#getDeveloperTools() + * @see DeveloperToolsPanel + */ +public class DeveloperTools { + + /** + * If {@code true}, textured polygon borders are drawn in yellow. + * Useful for visualizing polygon slicing for perspective-correct rendering. + */ + public volatile boolean showPolygonBorders = false; + + /** + * If {@code true}, only render even-numbered horizontal segments (0, 2, 4, 6). + * Odd segments (1, 3, 5, 7) will remain black. Useful for detecting + * if threads render outside their allocated screen area (overdraw detection). + */ + public volatile boolean renderAlternateSegments = false; + + /** + * If {@code true}, draws red horizontal lines at segment boundaries. + * Useful for visualizing which thread renders which screen area. + * Each line marks the boundary between two adjacent rendering segments. + */ + public volatile boolean showSegmentBoundaries = false; + + /** + * Creates a new DeveloperTools instance with all debug features disabled. + */ + public DeveloperTools() { + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeveloperToolsPanel.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeveloperToolsPanel.java new file mode 100644 index 0000000..b8b5a3a --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeveloperToolsPanel.java @@ -0,0 +1,646 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; + +import javax.swing.*; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.util.List; + +/** + * Developer tools panel for toggling diagnostic features and viewing logs. + * + *

Opens as a popup window when F12 is pressed. Provides:

+ *
    + *
  • Checkboxes to toggle debug settings
  • + *
  • Camera position display with copy button
  • + *
  • Composite shape frustum culling statistics
  • + *
  • A scrollable log viewer showing captured debug output
  • + *
  • A button to clear the log buffer
  • + *
  • Resizable window with native maximize support
  • + *
+ * + * @see DeveloperTools + * @see DebugLogBuffer + */ +public class DeveloperToolsPanel extends JFrame { + + private static final int UPDATE_INTERVAL_MS = 200; + + /** + * The view panel whose camera is being displayed. + */ + private final ViewPanel viewPanel; + /** + * The developer tools being controlled. + */ + private final DeveloperTools developerTools; + /** + * The log buffer being displayed. + */ + private final DebugLogBuffer debugLogBuffer; + /** + * The text area showing log messages. + */ + private final JTextArea logArea; + /** + * The label showing camera position. + */ + private final JLabel cameraLabel; + /** + * The label showing total composites count. + */ + private final JLabel totalCompositesLabel; + /** + * The label showing culled composites count. + */ + private final JLabel culledCompositesLabel; + /** + * The label showing culled percentage. + */ + private final JLabel culledPercentLabel; + /** + * The label showing current render thread count. + */ + private final JLabel renderThreadsLabel; + /** + * The label showing the current target FPS. + */ + private final JLabel targetFpsLabel; + /** + * The label showing the measured FPS. + */ + private final JLabel measuredFpsLabel; + /** + * Toggle button switching between target FPS and unlimited FPS. + */ + private final JToggleButton unlockFpsButton; + /** + * The per-thread activity timeline. + */ + private final ThreadTimelineComponent threadTimeline; + /** + * Toggle button enabling thread activity recording. + */ + private final JToggleButton recordTimelineButton; + /** + * Target FPS to restore when re-locking after an unlock. + */ + private int lockedFPS = 60; + /** + * Timer for periodic updates. + */ + private final Timer updateTimer; + /** + * Flag to prevent concurrent updates. + */ + private volatile boolean updating = false; + + /** + * Creates and displays a developer tools panel. + * + * @param parent the parent frame (for centering) + * @param viewPanel the view panel whose camera to display + * @param developerTools the developer tools to control + * @param debugLogBuffer the log buffer to display + */ + public DeveloperToolsPanel(final Frame parent, final ViewPanel viewPanel, + final DeveloperTools developerTools, + final DebugLogBuffer debugLogBuffer) { + super("Developer Tools"); + this.viewPanel = viewPanel; + this.developerTools = developerTools; + this.debugLogBuffer = debugLogBuffer; + + setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); + setLayout(new BorderLayout(8, 8)); + + cameraLabel = new JLabel(" "); + cameraLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + + // Initialize culling statistics labels + totalCompositesLabel = new JLabel("0"); + totalCompositesLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + culledCompositesLabel = new JLabel("0"); + culledCompositesLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + culledPercentLabel = new JLabel("0.0%"); + culledPercentLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + + // Initialize render threads label + renderThreadsLabel = new JLabel(String.valueOf(viewPanel.getNumRenderThreads())); + renderThreadsLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + + // Initialize frame rate display and unlock toggle + targetFpsLabel = new JLabel(" "); + targetFpsLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + measuredFpsLabel = new JLabel(" "); + measuredFpsLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + unlockFpsButton = new JToggleButton("Unlock FPS"); + unlockFpsButton.setToolTipText( + "Disable the frame rate cap so the application renders as fast as it can (benchmark mode)"); + unlockFpsButton.setSelected(viewPanel.getTargetFPS() <= 0); + unlockFpsButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(final ActionEvent e) { + if (unlockFpsButton.isSelected()) { + final int current = viewPanel.getTargetFPS(); + if (current > 0) { + lockedFPS = current; + } + viewPanel.setFrameRate(0); + } else { + viewPanel.setFrameRate(lockedFPS); + } + updateFrameRateLabels(); + } + }); + + // Thread activity timeline with record toggle + threadTimeline = new ThreadTimelineComponent(); + recordTimelineButton = new JToggleButton("Record"); + recordTimelineButton.setToolTipText( + "Record per-thread activity (transform/paint/binning per frame, idle = black) for the timeline below"); + recordTimelineButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(final ActionEvent e) { + ThreadActivityRecorder.setEnabled(recordTimelineButton.isSelected()); + } + }); + + final JPanel topPanel = new JPanel(); + topPanel.setLayout(new BoxLayout(topPanel, BoxLayout.Y_AXIS)); + topPanel.add(createSettingsPanel()); + topPanel.add(createCameraPanel()); + topPanel.add(createCullingPanel()); + topPanel.add(createRenderThreadsPanel()); + topPanel.add(createFrameRatePanel()); + topPanel.add(createThreadTimelinePanel()); + add(topPanel, BorderLayout.NORTH); + + logArea = new JTextArea(15, 60); + logArea.setEditable(false); + logArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + logArea.setBackground(Color.BLACK); + logArea.setForeground(Color.GREEN); + final JScrollPane scrollPane = new JScrollPane(logArea); + scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS); + add(scrollPane, BorderLayout.CENTER); + + final JPanel buttonPanel = createButtonPanel(); + add(buttonPanel, BorderLayout.SOUTH); + + pack(); + setLocationRelativeTo(parent); + + updateTimer = new Timer(UPDATE_INTERVAL_MS, new ActionListener() { + @Override + public void actionPerformed(final ActionEvent e) { + updateDisplay(); + } + }); + + addWindowListener(new WindowAdapter() { + @Override + public void windowOpened(final WindowEvent e) { + updateDisplay(); + updateTimer.start(); + } + + @Override + public void windowClosed(final WindowEvent e) { + updateTimer.stop(); + } + }); + } + + private JPanel createSettingsPanel() { + final JPanel panel = new JPanel(new GridLayout(0, 1, 0, 2)); + panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 0, 8)); + + final JCheckBox showBordersCheckbox = new JCheckBox("Show polygon borders"); + showBordersCheckbox.setSelected(developerTools.showPolygonBorders); + showBordersCheckbox.addChangeListener(new ChangeListener() { + @Override + public void stateChanged(final ChangeEvent e) { + developerTools.showPolygonBorders = showBordersCheckbox.isSelected(); + } + }); + + final JCheckBox alternateSegmentsCheckbox = new JCheckBox("Render alternate segments (overdraw debug)"); + alternateSegmentsCheckbox.setSelected(developerTools.renderAlternateSegments); + alternateSegmentsCheckbox.addChangeListener(new ChangeListener() { + @Override + public void stateChanged(final ChangeEvent e) { + developerTools.renderAlternateSegments = alternateSegmentsCheckbox.isSelected(); + } + }); + + final JCheckBox segmentBoundariesCheckbox = new JCheckBox("Show segment boundaries"); + segmentBoundariesCheckbox.setSelected(developerTools.showSegmentBoundaries); + segmentBoundariesCheckbox.addChangeListener(new ChangeListener() { + @Override + public void stateChanged(final ChangeEvent e) { + developerTools.showSegmentBoundaries = segmentBoundariesCheckbox.isSelected(); + } + }); + + panel.add(showBordersCheckbox); + panel.add(alternateSegmentsCheckbox); + panel.add(segmentBoundariesCheckbox); + + return panel; + } + + private JPanel createCameraPanel() { + final JPanel panel = new JPanel(new BorderLayout(4, 4)); + panel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(8, 8, 8, 8), + BorderFactory.createTitledBorder("Camera (x, y, z, yaw, pitch, roll)") + )); + + panel.add(cameraLabel, BorderLayout.CENTER); + + final JButton copyButton = new JButton("Copy"); + copyButton.setToolTipText("Copy camera position to clipboard"); + copyButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(final ActionEvent e) { + final String text = cameraLabel.getText(); + if (text != null && !text.trim().isEmpty()) { + final StringSelection sel = new StringSelection(text); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(sel, null); + } + } + }); + + final JButton pasteButton = new JButton("Paste"); + pasteButton.setToolTipText( + "Set camera position from clipboard (six comma-separated" + + " numbers: x, y, z, yaw, pitch, roll); does" + + " nothing if the clipboard holds anything else"); + pasteButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(final ActionEvent e) { + pasteCameraPosition(); + } + }); + + final JPanel buttons = new JPanel(new GridLayout(0, 1, 0, 4)); + buttons.add(copyButton); + buttons.add(pasteButton); + panel.add(buttons, BorderLayout.EAST); + + return panel; + } + + /** + * Strict decimal number: rejects NaN/Infinity/hex-float spellings + * that {@link Double#parseDouble(String)} would otherwise accept. + */ + private static final java.util.regex.Pattern NUMBER = + java.util.regex.Pattern.compile( + "[+-]?(\\d+\\.?\\d*|\\.\\d+)([eE][+-]?\\d+)?"); + + /** + * Reads the system clipboard and, if it holds exactly six + * comma-separated numbers (the format {@link #updateCameraLabel()} + * and the Copy button produce), teleports the camera to + * x, y, z, yaw, pitch, roll. Anything else in the clipboard is + * ignored silently. + */ + private void pasteCameraPosition() { + if (viewPanel == null) { + return; + } + final String text; + try { + final java.awt.datatransfer.Clipboard clipboard = + Toolkit.getDefaultToolkit().getSystemClipboard(); + if (!clipboard.isDataFlavorAvailable( + java.awt.datatransfer.DataFlavor.stringFlavor)) { + return; + } + text = (String) clipboard.getData( + java.awt.datatransfer.DataFlavor.stringFlavor); + } catch (final Exception ex) { + return; + } + if (text == null) { + return; + } + final String[] parts = text.trim().split(",", -1); + if (parts.length != 6) { + return; + } + final double[] values = new double[6]; + for (int i = 0; i < 6; i++) { + final String token = parts[i].trim(); + if (!NUMBER.matcher(token).matches()) { + return; + } + values[i] = Double.parseDouble(token); + } + viewPanel.getCamera().getTransform().set(values[0], values[1], + values[2], values[3], values[4], values[5]); + updateCameraLabel(); + } + + private JPanel createCullingPanel() { + final JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(0, 8, 8, 8), + BorderFactory.createTitledBorder("Composite shape frustum culling") + )); + + // Single row: total, culled, percent + final JPanel statsRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2)); + statsRow.add(new JLabel("Total:")); + statsRow.add(totalCompositesLabel); + statsRow.add(new JLabel(" Culled:")); + statsRow.add(culledCompositesLabel); + statsRow.add(culledPercentLabel); + + panel.add(statsRow); + + return panel; + } + + private JPanel createRenderThreadsPanel() { + final JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(0, 8, 8, 8), + BorderFactory.createTitledBorder("Render Threads") + )); + + final JPanel statsRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2)); + statsRow.add(new JLabel("Active:")); + statsRow.add(renderThreadsLabel); + statsRow.add(new JLabel(" Available cores:")); + statsRow.add(new JLabel(String.valueOf(Runtime.getRuntime().availableProcessors()))); + + panel.add(statsRow); + + return panel; + } + + private JPanel createFrameRatePanel() { + final JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(0, 8, 8, 8), + BorderFactory.createTitledBorder("Frame Rate") + )); + + final JPanel statsRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2)); + statsRow.add(new JLabel("Target:")); + statsRow.add(targetFpsLabel); + statsRow.add(new JLabel(" Measured:")); + statsRow.add(measuredFpsLabel); + statsRow.add(unlockFpsButton); + + panel.add(statsRow); + + return panel; + } + + private JPanel createThreadTimelinePanel() { + final JPanel panel = new JPanel(new BorderLayout(4, 4)); + panel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(0, 8, 8, 8), + BorderFactory.createTitledBorder("Thread Timeline (idle = black; wheel = scroll, ctrl+wheel = zoom)") + )); + panel.add(recordTimelineButton, BorderLayout.NORTH); + panel.add(threadTimeline, BorderLayout.CENTER); + panel.add(threadTimeline.scrollBar, BorderLayout.SOUTH); + return panel; + } + + private JPanel createButtonPanel() { + final JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + + final JButton clearButton = new JButton("Clear Logs"); + clearButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(final ActionEvent e) { + debugLogBuffer.clear(); + logArea.setText(""); + } + }); + + final JButton bugReportButton = new JButton("Make Bug Report..."); + bugReportButton.setToolTipText( + "Write a bug report directory: your description, a" + + " screenshot, camera position, logs and memory" + + " statistics"); + bugReportButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(final ActionEvent e) { + showBugReportDialog(); + } + }); + + panel.add(clearButton); + panel.add(bugReportButton); + + return panel; + } + + /** + * Popup asking what happened, then writes a bug report directory + * (screenshot + description + logs + memory stats) and shows the + * resulting path. + */ + private void showBugReportDialog() { + final JTextArea descriptionArea = new JTextArea(8, 50); + descriptionArea.setLineWrap(true); + descriptionArea.setWrapStyleWord(true); + final JScrollPane scroll = new JScrollPane(descriptionArea); + scroll.setBorder(BorderFactory.createTitledBorder( + "What happened / what looks wrong?")); + + final int choice = JOptionPane.showConfirmDialog(this, scroll, + "Make Bug Report", JOptionPane.OK_CANCEL_OPTION, + JOptionPane.PLAIN_MESSAGE); + if (choice != JOptionPane.OK_OPTION) + return; + + try { + final java.io.File dir = BugReport.create(viewPanel, + descriptionArea.getText()); + showBugReportResultDialog(dir); + } catch (final Exception ex) { + JOptionPane.showMessageDialog(this, + "Bug report failed: " + ex.getMessage(), + "Bug Report", JOptionPane.ERROR_MESSAGE); + } + } + + /** + * Shows the created bug report's location in a selectable text + * field (copy-paste with Ctrl+C works) plus explicit Copy Path and + * Open Folder buttons — a plain JOptionPane message is neither + * selectable nor actionable. + */ + private void showBugReportResultDialog(final java.io.File dir) { + final JDialog dialog = new JDialog(this, "Bug Report", true); + dialog.setLayout(new BorderLayout(8, 8)); + + final JPanel centerPanel = new JPanel(new BorderLayout(4, 4)); + centerPanel.setBorder(BorderFactory.createEmptyBorder(8, 8, 0, 8)); + centerPanel.add(new JLabel( + "Bug report written to (point the developer at this" + + " directory):"), BorderLayout.NORTH); + final JTextField pathField = new JTextField( + dir.getAbsolutePath()); + pathField.setEditable(false); + pathField.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + centerPanel.add(pathField, BorderLayout.CENTER); + dialog.add(centerPanel, BorderLayout.CENTER); + + final JPanel buttonPanel = new JPanel( + new FlowLayout(FlowLayout.LEFT)); + + final JButton copyButton = new JButton("Copy Path"); + copyButton.setToolTipText("Copy the report path to the clipboard"); + copyButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(final ActionEvent e) { + final StringSelection sel = new StringSelection( + dir.getAbsolutePath()); + Toolkit.getDefaultToolkit().getSystemClipboard() + .setContents(sel, null); + } + }); + buttonPanel.add(copyButton); + + final boolean openSupported = Desktop.isDesktopSupported() + && Desktop.getDesktop() + .isSupported(Desktop.Action.OPEN); + final JButton openButton = new JButton("Open Folder"); + openButton.setToolTipText(openSupported + ? "Open the report directory in the file manager" + : "Opening folders is not supported on this desktop"); + openButton.setEnabled(openSupported); + if (openSupported) { + openButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(final ActionEvent e) { + try { + Desktop.getDesktop().open(dir); + } catch (final Exception ex) { + JOptionPane.showMessageDialog(dialog, + "Could not open folder: " + + ex.getMessage(), + "Bug Report", JOptionPane.ERROR_MESSAGE); + } + } + }); + } + buttonPanel.add(openButton); + + final JButton closeButton = new JButton("Close"); + closeButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(final ActionEvent e) { + dialog.dispose(); + } + }); + buttonPanel.add(closeButton); + + dialog.add(buttonPanel, BorderLayout.SOUTH); + dialog.pack(); + dialog.setLocationRelativeTo(this); + dialog.setVisible(true); + } + + private void updateDisplay() { + if (updating) { + return; + } + updating = true; + try { + updateCameraLabel(); + updateCullingStatistics(); + updateRenderThreadsLabel(); + updateFrameRateLabels(); + updateLogDisplay(); + threadTimeline.repaint(); + } finally { + updating = false; + } + } + + private void updateCameraLabel() { + if (viewPanel == null) { + return; + } + + final Camera camera = viewPanel.getCamera(); + final Point3D pos = camera.getTransform().getTranslation(); + final double[] angles = camera.getTransform().getRotation().toAngles(); + + cameraLabel.setText(String.format("%.2f, %.2f, %.2f, %.2f, %.2f, %.2f", + pos.x, pos.y, pos.z, angles[0], angles[1], angles[2])); + } + + private void updateCullingStatistics() { + if (viewPanel == null) { + return; + } + + // Get the current rendering context from view panel's last render + final RenderingContext context = viewPanel.getRenderingContext(); + if (context == null || context.cullingStatistics == null) { + totalCompositesLabel.setText("-"); + culledCompositesLabel.setText("-"); + culledPercentLabel.setText("-"); + return; + } + + final CullingStatistics stats = context.cullingStatistics; + totalCompositesLabel.setText(String.valueOf(stats.totalComposites.get())); + culledCompositesLabel.setText(String.valueOf(stats.culledComposites.get())); + culledPercentLabel.setText(String.format(" (%.1f%%)", stats.getCulledPercentage())); + } + + private void updateRenderThreadsLabel() { + if (viewPanel == null) { + return; + } + renderThreadsLabel.setText(String.valueOf(viewPanel.getNumRenderThreads())); + } + + private void updateFrameRateLabels() { + if (viewPanel == null) { + return; + } + final int target = viewPanel.getTargetFPS(); + targetFpsLabel.setText(target > 0 ? String.valueOf(target) : "unlimited"); + measuredFpsLabel.setText(String.format("%.1f", viewPanel.getMeasuredFPS())); + } + + private void updateLogDisplay() { + final List entries = debugLogBuffer.getEntries(); + final StringBuilder sb = new StringBuilder(); + for (final String entry : entries) { + sb.append(entry).append('\n'); + } + logArea.setText(sb.toString()); + + final JScrollBar vertical = ((JScrollPane) logArea.getParent().getParent()) + .getVerticalScrollBar(); + vertical.setValue(vertical.getMaximum()); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/FrameListener.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/FrameListener.java new file mode 100644 index 0000000..f7f12b9 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/FrameListener.java @@ -0,0 +1,52 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +/** + * Listener interface for per-frame callbacks before the 3D scene is rendered. + * + *

Implement this interface and register it with + * {@link ViewPanel#addFrameListener(FrameListener)} to receive a callback + * before each frame. This is the primary mechanism for implementing animations, + * physics updates, and other time-dependent behavior.

+ * + *

Usage example - animating a shape:

+ *
{@code
+ * viewPanel.addFrameListener((panel, deltaMs) -> {
+ *     // Rotate the shape a little each frame
+ *     double angleIncrement = deltaMs * 0.001;  // radians per millisecond
+ *     myShape.setTransform(new Transform(
+ *         myShape.getLocation(),
+ *         currentAngle += angleIncrement, 0
+ *     ));
+ *     return true;  // request repaint since we changed something
+ * });
+ * }
+ * + *

The engine uses the return values to optimize rendering: if no listener + * returns {@code true} and no other changes occurred, the frame is skipped + * to save CPU and energy.

+ * + * @see ViewPanel#addFrameListener(FrameListener) + * @see ViewPanel#removeFrameListener(FrameListener) + */ +public interface FrameListener { + + /** + * Called before each frame render, allowing the listener to update state + * and indicate whether a repaint is needed. + * + *

Each registered listener is called exactly once per frame tick. + * The frame is only rendered if at least one listener returns {@code true} + * (or if the view was explicitly marked for repaint).

+ * + * @param viewPanel the view panel being rendered + * @param millisecondsSinceLastFrame time elapsed since the previous frame, + * for frame-rate-independent updates + * @return {@code true} if the view should be re-rendered this frame, + * {@code false} if this listener has no visual changes + */ + boolean onFrame(ViewPanel viewPanel, int millisecondsSinceLastFrame); +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/GuiComponent.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/GuiComponent.java new file mode 100644 index 0000000..a86a807 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/GuiComponent.java @@ -0,0 +1,208 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +import eu.svjatoslav.aukio.e3d.geometry.Box; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardHelper; +import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardInputHandler; +import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent; +import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController; +import eu.svjatoslav.aukio.e3d.math.Transform; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.WireframeBox; + +import java.awt.event.KeyEvent; + +/** + * Base class for interactive GUI components rendered in 3D space. + * + *

{@code GuiComponent} combines a composite shape with keyboard and mouse interaction + * handling. When clicked, it acquires keyboard focus (via the {@link eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardFocusStack}), + * and a red wireframe border is displayed to indicate focus. Pressing ESC releases focus.

+ * + *

This class is the foundation for interactive widgets like the + * {@link eu.svjatoslav.aukio.e3d.gui.textEditorComponent.TextEditComponent}.

+ * + *

Usage example - creating a custom GUI component:

+ *
{@code
+ * GuiComponent myWidget = new GuiComponent(
+ *     new Transform(new Point3D(0, 0, 300)),
+ *     viewPanel,
+ *     new Point3D(400, 300, 0)  // width, height, depth
+ * );
+ *
+ * // Add visual content to the widget
+ * myWidget.addShape(someTextCanvas);
+ *
+ * // Add to the scene
+ * viewPanel.getRootShapeCollection().addShape(myWidget);
+ * }
+ * + * @see eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardFocusStack manages which component has keyboard focus + * @see eu.svjatoslav.aukio.e3d.gui.textEditorComponent.TextEditComponent a full text editor built on this class + */ +public class GuiComponent extends AbstractCompositeShape implements + KeyboardInputHandler, MouseInteractionController { + + private static final String GROUP_GUI_FOCUS = "gui.focus"; + + /** + * The view panel this component is attached to. + */ + public final ViewPanel viewPanel; + Box containingBox = new Box(); + private WireframeBox borders = null; + + private boolean borderShown = false; + + /** + * Creates a GUI component with the specified transform, view panel, and bounding box size. + * + * @param transform the position and orientation of the component in 3D space + * @param viewPanel the view panel this component belongs to + * @param size the bounding box dimensions (width, height, depth) + */ + public GuiComponent(final Transform transform, + final ViewPanel viewPanel, final Point3D size) { + super(transform); + this.viewPanel = viewPanel; + setDimensions(size); + } + + private WireframeBox createBorder() { + final LineAppearance appearance = new LineAppearance(10, + new eu.svjatoslav.aukio.e3d.renderer.raster.Color(255, 0, 0, 100)); + + final double borderSize = 10; + + final Box borderArea = containingBox.clone().enlarge(borderSize); + + return new WireframeBox(borderArea, appearance); + } + + @Override + public boolean focusLost(final ViewPanel viewPanel) { + hideBorder(); + return true; + } + + @Override + public boolean focusReceived(final ViewPanel viewPanel) { + showBorder(); + return true; + } + + /** + * Returns whether this component currently holds keyboard focus. + * + * @return {@code true} when focused (focus border is shown) + */ + public boolean hasKeyboardFocus() { + return borderShown; + } + + /** + * Returns the wireframe border box for this component. + * + * @return the border wireframe box + */ + public WireframeBox getBorders() { + if (borders == null) + borders = createBorder(); + return borders; + } + + /** + * Returns the depth of this component's bounding box. + * + * @return the depth in pixels + */ + public int getDepth() { + return (int) containingBox.getDepth(); + } + + /** + * Returns the height of this component's bounding box. + * + * @return the height in pixels + */ + public int getHeight() { + return (int) containingBox.getHeight(); + } + + /** + * Returns the width of this component's bounding box. + * + * @return the width in pixels + */ + public int getWidth() { + return (int) containingBox.getWidth(); + } + + /** + * Hides the focus border around this component. + */ + public void hideBorder() { + if (!borderShown) + return; + borderShown = false; + removeGroup(GROUP_GUI_FOCUS); + } + + @Override + public boolean keyPressed(final KeyEvent event, final ViewPanel viewPanel) { + if (event.getKeyChar() == KeyboardHelper.ESC) + viewPanel.getKeyboardFocusStack().popFocusOwner(); + return true; + } + + @Override + public boolean keyReleased(final KeyEvent event, final ViewPanel viewPanel) { + return false; + } + + @Override + public boolean mouseClicked(int button) { + if (button == MouseEvent.BUTTON_MIDDLE) { + // middle click releases keyboard focus, like ESC + viewPanel.getKeyboardFocusStack().popFocusOwner(); + return true; + } + return viewPanel.getKeyboardFocusStack().pushFocusOwner(this); + } + + @Override + public boolean mouseWheelMoved(final int verticalUnits, + final int horizontalUnits) { + // a focused GUI component owns the scroll wheel (the camera must + // not move while a component is focused); subclasses like the + // terminal and browser panels forward the scroll to their app + return true; + } + + @Override + public boolean mouseEntered() { + return false; + } + + @Override + public boolean mouseExited() { + return false; + } + + private void setDimensions(final Point3D size) { + containingBox.setBoxSize(size); + } + + private void showBorder() { + if (borderShown) + return; + borderShown = true; + addShape(getBorders(), GROUP_GUI_FOCUS); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/HiZPyramid.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/HiZPyramid.java new file mode 100644 index 0000000..a12608b --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/HiZPyramid.java @@ -0,0 +1,198 @@ +package eu.svjatoslav.aukio.e3d.gui; + +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Hierarchical depth pyramid for whole-block occlusion culling + * (Hi-Z). Built from the just-painted frame's depth buffer; queried + * during the NEXT frame's transform to skip blocks that are fully + * hidden behind what was drawn last frame. + * + *

Semantics: each tile stores the MINIMUM w (= 1/z, i.e. the + * FARTHEST written depth) over its pixels. A block whose nearest + * possible point (max w over its AABB corners) is farther than a + * tile's farthest written depth is behind something at every written + * pixel of that tile — and tiles with any unwritten (sky) pixel hold + * -infinity and never occlude. That makes the test conservative: it + * may keep a hidden block, it never culls a visible one — under a + * static camera. (The first version max-pooled, storing the NEAREST + * depth per tile; that culled houses visible BETWEEN nearer tree + * trunks — per-pixel gaps inside a tile are invisible to a max.) + * Camera motion reuses the stale pyramid, which can cull a + * newly-visible block wrongly; the block's pixels then contain far + * background depth, so the next frame's pyramid no longer occludes + * it — wrong culls self-heal in one frame (sanctioned design + * concession), and the query margin absorbs small-motion + * parallax.

+ * + *

Empty tiles (never depth-written) store -infinity and never + * occlude, so the first frame after startup (or after any pass that + * leaves the pyramid unbuilt) culls nothing.

+ * + *

Knobs: {@code -Daukio.hiz=false} disables culling (build still + * happens — it is cheap — so flipping the flag needs no warm-up); + * {@code -Daukio.hiz.margin=0.02} sets the relative w-space safety + * margin against parallax between frames.

+ */ +public final class HiZPyramid { + + /** Level-0 tile edge in pixels; level i tiles cover TILE*2^i px. */ + private static final int TILE = 8; + + private static final boolean ENABLED = Boolean.parseBoolean( + System.getProperty("aukio.hiz", "true")); + private static final double MARGIN = Double.parseDouble( + System.getProperty("aukio.hiz.margin", "0.02")); + + /** Blocks occlusion-tested this process (telemetry). */ + public final AtomicLong blocksTested = new AtomicLong(); + /** Blocks culled as fully occluded (telemetry). */ + public final AtomicLong blocksCulled = new AtomicLong(); + + private float[] tiles = new float[0]; + private int[] levelOff = new int[0]; + private int[] levelW = new int[0]; + private int[] levelH = new int[0]; + private int levels; + private int bufW = -1, bufH = -1; + + /** Rebuilds the pyramid from a freshly painted depth buffer. */ + public synchronized void buildFrom(final float[] depth, + final int width, final int height) { + if (width != bufW || height != bufH) { + allocate(width, height); + } + + // Level 0: min-pool depth into TILE x TILE tiles. Depth + // outside the painted area is -infinity (cleared), so a tile + // with any unpainted pixel never occludes. + final int w0 = levelW[0], h0 = levelH[0]; + final int off0 = levelOff[0]; + for (int ty = 0; ty < h0; ty++) { + final int yEnd = Math.min((ty + 1) * TILE, height); + for (int tx = 0; tx < w0; tx++) { + final int xEnd = Math.min((tx + 1) * TILE, width); + float m = Float.POSITIVE_INFINITY; + for (int y = ty * TILE; y < yEnd; y++) { + final int row = y * width; + for (int x = tx * TILE; x < xEnd; x++) + if (depth[row + x] < m) + m = depth[row + x]; + } + tiles[off0 + ty * w0 + tx] = m; + } + } + + // Higher levels: min of 2x2 children. + for (int l = 1; l < levels; l++) { + final int pw = levelW[l - 1], ph = levelH[l - 1]; + final int poff = levelOff[l - 1]; + final int cw = levelW[l], ch = levelH[l]; + final int coff = levelOff[l]; + for (int ty = 0; ty < ch; ty++) + for (int tx = 0; tx < cw; tx++) { + float m = Float.POSITIVE_INFINITY; + for (int dy = 0; dy < 2; dy++) + for (int dx = 0; dx < 2; dx++) { + final int sx = tx * 2 + dx, sy = ty * 2 + dy; + if (sx < pw && sy < ph) { + final float v = tiles[poff + sy * pw + sx]; + if (v < m) + m = v; + } + } + tiles[coff + ty * cw + tx] = m; + } + } + } + + /** + * Conservative whole-block occlusion test. + * + * @param x1..y2 screen-space AABB of the block (will be clamped + * to the buffer; a fully off-screen box returns + * false) + * @param nearestW the block's nearest possible depth = MAX 1/z + * over its corners + * @return true when the block is certainly hidden behind last + * frame's occluders (within the parallax margin) + */ + public synchronized boolean occluded(final double x1, final double y1, + final double x2, final double y2, + final double nearestW) { + if (!ENABLED || levels == 0) + return false; + + int bx1 = (int) Math.floor(x1), by1 = (int) Math.floor(y1); + int bx2 = (int) Math.ceil(x2), by2 = (int) Math.ceil(y2); + if (bx1 < 0) bx1 = 0; + if (by1 < 0) by1 = 0; + if (bx2 >= bufW) bx2 = bufW - 1; + if (by2 >= bufH) by2 = bufH - 1; + if (bx1 > bx2 || by1 > by2) + return false; + + // Coarsest level where the box still covers <= 2 tiles per axis. + int level = 0; + while (level + 1 < levels) { + final int s = TILE << (level + 1); + final int tw = (bx2 / s) - (bx1 / s) + 1; + final int th = (by2 / s) - (by1 / s) + 1; + if (tw > 2 || th > 2) + break; + level++; + } + + final int s = TILE << level; + final int tx1 = bx1 / s, ty1 = by1 / s; + final int tx2 = bx2 / s, ty2 = by2 / s; + final int w = levelW[level], off = levelOff[level]; + + float minStored = Float.POSITIVE_INFINITY; + for (int ty = ty1; ty <= ty2; ty++) + for (int tx = tx1; tx <= tx2; tx++) { + final float v = tiles[off + ty * w + tx]; + if (v < minStored) + minStored = v; + } + + // Occluded only when the block's nearest point is clearly + // behind the farthest written depth in the range; the + // relative margin absorbs parallax between frames. + return nearestW < minStored * (1.0 - MARGIN); + } + + private void allocate(final int width, final int height) { + bufW = width; + bufH = height; + int lw = (width + TILE - 1) / TILE; + int lh = (height + TILE - 1) / TILE; + int count = 0; + levels = 0; + while (true) { + levels++; + count += lw * lh; + if (lw == 1 && lh == 1) + break; + lw = Math.max(1, (lw + 1) / 2); + lh = Math.max(1, (lh + 1) / 2); + } + tiles = new float[count]; + levelOff = new int[levels]; + levelW = new int[levels]; + levelH = new int[levels]; + lw = (width + TILE - 1) / TILE; + lh = (height + TILE - 1) / TILE; + int off = 0; + for (int l = 0; l < levels; l++) { + levelOff[l] = off; + levelW[l] = lw; + levelH[l] = lh; + off += lw * lh; + lw = Math.max(1, (lw + 1) / 2); + lh = Math.max(1, (lh + 1) / 2); + } + Arrays.fill(tiles, Float.NEGATIVE_INFINITY); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/RenderingContext.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/RenderingContext.java new file mode 100644 index 0000000..22b90c2 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/RenderingContext.java @@ -0,0 +1,707 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +import eu.svjatoslav.aukio.e3d.geometry.Frustum; +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent; +import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController; +import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; +import java.awt.image.WritableRaster; +import java.util.concurrent.ExecutorService; +import eu.svjatoslav.aukio.e3d.renderer.raster.ParallelTransformCoordinator; +import java.util.function.Consumer; + +/** + * Contains all state needed to render a single frame: the pixel buffer, graphics context, + * screen dimensions, and mouse event tracking. + * + *

A new {@code RenderingContext} is created whenever the view panel is resized. + * During rendering, shapes use this context to:

+ *
    + *
  • Access the raw pixel array ({@link #pixels}) for direct pixel manipulation
  • + *
  • Access the {@link Graphics2D} context ({@link #graphics}) for Java2D drawing
  • + *
  • Read screen dimensions ({@link #width}, {@link #height}) and the + * {@link #centerCoordinate} for coordinate projection
  • + *
  • Use the {@link #projectionScale} factor for perspective projection
  • + *
+ * + *

The context also manages mouse interaction detection: as shapes are painted + * back-to-front, each shape can report itself as the object under the mouse cursor. + * After painting completes, the topmost shape receives the mouse event.

+ * + * @see ViewPanel the panel that creates and manages this context + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape#paint(RenderingContext) + */ +public class RenderingContext { + + /** + * The {@link BufferedImage} pixel format used for the rendering buffer. + * TYPE_INT_RGB provides optimal performance for Java2D blitting. + */ + public static final int bufferedImageType = BufferedImage.TYPE_INT_RGB; + + /** + * Number of horizontal segments (bands) for parallel rendering. + * Bands are finer than the paint thread count: paint threads steal + * bands off a shared ticket until all bands are done, so a thread + * that finishes a cheap band immediately picks up more work. + * Derived from the render thread count via + * {@link ViewPanel#setNumRenderThreads(int)}. + * + *

Equals {@code tilesX * tilesY * viewportCount}: the tile grid + * covers one viewport, and in stereo mode a second grid covers the + * other eye (segment indices for the right eye start at + * {@code tilesX * tilesY}).

+ */ + public final int numRenderSegments; + + /** Tile columns per viewport (1 = horizontal bands only). */ + public final int tilesX; + + /** Tile rows per viewport. */ + public final int tilesY; + + /** Number of side-by-side viewports (2 in stereo mode, else 1). */ + public final int viewportCount; + + /** + * Java2D graphics context for drawing text, anti-aliased shapes, and other + * high-level graphics operations onto the render buffer. + */ + public final Graphics2D graphics; + + /** + * Segment-specific Graphics2D contexts, each pre-clipped to a horizontal band. + * Used for thread-safe text and shape rendering without synchronization. + * Only initialized in the main RenderingContext; null in segment views. + */ + private Graphics2D[] segmentGraphics; + + /** + * Pixels of the rendering area. + * Each pixel is a single int in RGB format: {@code (r << 16) | (g << 8) | b}. + */ + public final int[] pixels; + + /** + * Per-pixel depth (biased 1/z, larger = nearer), always allocated — + * the painter path was deleted 2026-09-17 and the z-buffer is the + * only visibility mechanism. Shared with segment/pass copies like + * {@link #pixels}. Cleared per tile by the paint workers. + */ + public float[] depth; + + /** + * Active paint pass, set internally by + * {@code RenderAggregator.paintSorted}: 0 = not painting, 1 = + * opaque pass (opaque-class triangles only, depth test + write), + * 2 = alpha pass (alpha-carrying + * triangles only, depth test, no depth write). Shapes read it to + * decide whether they belong to the current pass. + */ + public int depthPass; + + /** + * Depth tolerance in world units for the z-buffer test, in the form + * {@code zw > stored - DEPTH_MARGIN_DZ * zw * zw} (tolerance behind + * stored, per-pixel at fragment depth). Default 0 = strict depth: any + * nonzero window exports per-triangle painter-sort errors into + * per-pixel occlusion errors (dirt whose triangles sort late beats + * road pavement that strictly wins at margin 0 — user bugreport + * 2026-09-16, road pose). Tunable via -Daukio.zbuffer.margin. + */ + public static final double DEPTH_MARGIN_DZ = + Double.parseDouble(System.getProperty("aukio.zbuffer.margin", "0")); + + /** + * Width of the rendering area in pixels. + */ + public final int width; + + /** + * Height of the rendering area in pixels. + */ + public final int height; + + /** + * Center of the screen in screen space (pixels). + * This is the point where (0,0) coordinate of the world space is rendered. + */ + public final Point2D centerCoordinate; + + /** + * Scale factor for perspective projection, derived from screen width. + * Used to convert normalized device coordinates to screen pixels. + * This is mutable to support stereo rendering where each eye has a different viewport width. + */ + public double projectionScale; + + /** + * Minimum Y coordinate (inclusive) to render. Used for multi-threaded rendering + * where each thread renders a horizontal segment. + */ + public final int renderMinY; + + /** + * Maximum Y coordinate (exclusive) to render. Used for multi-threaded rendering + * where each thread renders a horizontal segment. + */ + public final int renderMaxY; + + final BufferedImage bufferedImage; + /** + * Unique id of the current transform cycle, assigned by + * {@code ShapeCollection.transformShapes()} from a global counter. + * Unlike {@link #frameNumber} (per-context, can repeat across context + * instances), this never collides, so per-cycle memoization such as + * composite subtree weights can safely key on it. + */ + public long transformCycleId; + + /** + * Which projection buffer slot this context writes/reads: 0, 1 or 2. + * Cycles per render pass (per eye in stereo) when the + * triple-buffered pipeline is active, so the transform phase of a + * pass never overwrites the vertex state either of the two previous + * passes' paints may still be reading. Always 0 when the pipeline is + * off (tests, single-pass rendering). + */ + public int vertexSlot = 0; + + /** + * Near-plane distance in camera-space Z units. Polygons whose vertices + * straddle this plane are clipped against it (new intersection vertices + * are generated with interpolated UVs); polygons fully behind it are + * culled. Must be > 0 so the perspective divide stays safe. + */ + public double nearPlaneDistance = 1.0; + + /** + * Number of frame that is currently being rendered. + * Every frame has its own number. + */ + public int frameNumber = 0; + + /** + * Projected-size cull threshold in screen pixels: shapes whose screen + * bounds span less than this in both axes are not queued for + * rendering. 0 (the default) disables the cull. Set globally with + * {@code -Daukio.cull.subpixel=}. + */ + public double subpixelCullingThreshold = Double.parseDouble( + System.getProperty("aukio.cull.subpixel", "0")); + + /** + * Epoch of the subpixel-culling verdict cache, stamped per frame by + * {@code ShapeCollection.transformShapesBegin}: the epoch advances + * when the camera moves significantly (or after a bounded number of + * frames), which invalidates all cached skip verdicts and forces one + * re-evaluation pass. Meaningless when the cull is off. + */ + public int subpixelCullingEpoch; + + /** + * UI component that mouse is currently hovering over. + */ + private MouseInteractionController objectPreviouslyUnderMouseCursor; + /** + * Mouse click event that needs to be processed. + * This event is processed only once per frame. + * If there are multiple objects under the mouse cursor, the top-most object will receive the event. + * If there are no objects under the mouse cursor, the event will be ignored. + * If there is no event, this field will be null. + * This field is set to null after the event is processed. + */ + private MouseEvent mouseEvent; + /** + * UI component that mouse is currently hovering over. + */ + private MouseInteractionController currentObjectUnderMouseCursor; + /** + * Texture coordinates of the mouse cursor on the hit shape (primary + * texture pixels), or NaN when the hit shape has no texture. + */ + private double currentMouseTextureU = Double.NaN; + private double currentMouseTextureV = Double.NaN; + /** + * Developer tools for this rendering context. + * Controls diagnostic features like logging and visualization. + */ + public DeveloperTools developerTools; + + /** + * Debug log buffer for capturing diagnostic output. + * Shapes can log messages here that appear in the Developer Tools panel. + */ + public DebugLogBuffer debugLogBuffer; + + /** + * Global lighting manager for the scene. + * All shaded polygons use this to calculate lighting. Contains all light sources + * and ambient light settings for the world. + */ + public LightingManager lightingManager; + + /** + * Which eye is being rendered in stereo mode. NONE for normal single-view rendering. + */ + public StereoEye stereoEye = StereoEye.NONE; + + /** + * Width of the viewport for the current eye in stereo mode. + * Equals {@link #width} when not in stereo mode. + */ + public int stereoViewportWidth; + + /** + * X offset of the current eye's viewport within the full buffer. + * 0 for left eye, width/2 for right eye, 0 in normal mode. + */ + public int stereoViewportOffsetX; + + /** + * Minimum X coordinate (inclusive) for rendering. + * In stereo mode, this is {@link #stereoViewportOffsetX}. + * In normal mode, this is 0. + */ + public int renderMinX; + + /** + * Maximum X coordinate (exclusive) for rendering. + * In stereo mode, this is {@link #stereoViewportOffsetX} + {@link #stereoViewportWidth}. + * In normal mode, this is {@link #width}. + */ + public int renderMaxX; + + /** + * View frustum for frustum culling. + * Updated each frame from camera state and screen dimensions. + * Shapes can test their bounding boxes against this frustum to determine + * if they are potentially visible before expensive vertex transformations. + */ + public Frustum frustum; + + /** + * World-space position of the viewer for this pass, copied from the + * camera in {@link eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection#transformShapesBegin}. + * Used by BSP painter ordering (viewpoint for tree traversal). + * Fresh instance per context, including per-pass copies, so overlapping + * pipeline passes each see their own viewpoint. + */ + public final eu.svjatoslav.aukio.e3d.geometry.Point3D viewerPosition = + new eu.svjatoslav.aukio.e3d.geometry.Point3D(); + + /** + * Statistics for frustum culling performance tracking. + * Updated each frame: total shapes counted at start, visible shapes + * incremented during rendering, culled composites tracked during transform. + */ + public CullingStatistics cullingStatistics; + + /** + * Hi-Z occlusion pyramid, rebuilt from the depth buffer after every + * painted frame (live {@code ViewPanel} path only — headless + * {@code Snapshot} renders leave it empty so golden renders never + * cull). Read during the next frame's transform by + * {@code TriangleMeshBlock} to skip fully occluded blocks. Shared + * with pass/segment copies like {@link #depth}. + */ + public HiZPyramid occlusionPyramid; + + /** + * Executor for the parallel transform phase. When non-null, composites + * with enough children split their render lists into chunks transformed + * concurrently. When null, the transform phase runs serially on the + * render thread. + */ + public ExecutorService transformExecutor; + + /** + * Per-frame coordinator for the non-blocking parallel transform fork. + * Set by {@code ShapeCollection.transformShapes()} for the duration of + * the root transform when {@link #transformExecutor} is available; + * composites at any nesting level submit chunk tasks to it. Null outside + * the transform phase and when transforming serially. + */ + public ParallelTransformCoordinator transformCoordinator; + + /** + * Present gate for this framebuffer: fires when the frame currently + * held in this buffer has been presented to the display (or dropped + * from the presentation mailbox). The render thread installs a fresh + * gate at the start of each frame that reuses the buffer, and the + * frame's paint continuation awaits the PREVIOUS gate before writing + * pixels — without it, painting frame F+3 would overwrite the buffer + * while the present thread is still blitting frame F from it. + */ + public volatile java.util.concurrent.CountDownLatch presentGate = new java.util.concurrent.CountDownLatch(0); + + /** + * Chunk tasks submitted during the last transform phase, across all + * nesting levels. Diagnostics: proves nested composites forked. + */ + public int lastTransformTaskCount; + + /** + * Creates a new rendering context for full-screen rendering. + * + *

Equivalent to {@code RenderingContext(width, height, 0, height, numRenderSegments)}.

+ * + * @param width the rendering area width in pixels + * @param height the rendering area height in pixels + * @param numRenderSegments number of parallel render segments (threads) + */ + public RenderingContext(final int width, final int height, final int numRenderSegments) { + this(width, height, 0, height, 1, numRenderSegments, 1); + } + + /** + * Creates a new rendering context with a rectangular tile grid. + * + *

Equivalent to the band-only constructors when {@code tilesX == 1}. + * In stereo mode ({@code viewportCount == 2}) each viewport gets its own + * tile grid; segment indices for viewport v start at + * {@code v * tilesX * tilesY}.

+ * + * @param width the rendering area width in pixels + * @param height the rendering area height in pixels + * @param tilesX tile columns per viewport (1 = bands only) + * @param tilesY tile rows per viewport + * @param viewportCount number of side-by-side viewports (2 = stereo) + */ + public RenderingContext(final int width, final int height, + final int tilesX, final int tilesY, + final int viewportCount) { + this(width, height, 0, height, tilesX, tilesY, viewportCount); + } + + private RenderingContext(final int width, final int height, + final int renderMinY, final int renderMaxY, + final int tilesX, final int tilesY, + final int viewportCount) { + this.width = width; + this.height = height; + this.renderMinY = renderMinY; + this.renderMaxY = renderMaxY; + this.tilesX = tilesX; + this.tilesY = tilesY; + this.viewportCount = viewportCount; + this.numRenderSegments = tilesX * tilesY * viewportCount; + this.centerCoordinate = new Point2D(width / 2d, height / 2d); + this.projectionScale = width / 3d; + this.stereoViewportWidth = width; + this.stereoViewportOffsetX = 0; + this.renderMinX = 0; + this.renderMaxX = width; + + // Eagerly allocated so the developer-tools panel always finds it: + // transformPass() hands the pipeline a per-pass COPY of this context, + // and the copy constructor shares this reference. Lazy creation in + // ShapeCollection.transformShapesBegin() would only ever populate the + // throwaway pass copy, leaving this frame context null forever + // (the culling display then showed "-" permanently). + this.cullingStatistics = new CullingStatistics(); + this.occlusionPyramid = new HiZPyramid(); + + bufferedImage = new BufferedImage(width, height, bufferedImageType); + + final WritableRaster raster = bufferedImage.getRaster(); + final DataBufferInt dbi = (DataBufferInt) raster.getDataBuffer(); + pixels = dbi.getData(); + + // Z-buffer mode (-Daukio.zbuffer=true): one w-depth (biased 1/z) + // value per pixel, cleared per tile in the paint workers. Depth + // turns the queue order into a performance heuristic only; + // correctness comes from the per-pixel test. (The queue itself + // stays painter back-to-front — Z descending, see + // RenderAggregator.) Null in classic painter mode. + depth = new float[width * height]; + + graphics = (Graphics2D) bufferedImage.getGraphics(); + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + + segmentGraphics = createSegmentGraphics(); + } + + /** + * Protected constructor for creating segment views. + * Shares the pixel buffer and graphics context with the parent. + * + * @param parent the parent rendering context + * @param renderMinY minimum Y coordinate (inclusive) for this segment + * @param renderMaxY maximum Y coordinate (exclusive) for this segment + */ + protected RenderingContext(final RenderingContext parent, + final int renderMinY, final int renderMaxY) { + this.width = parent.width; + this.height = parent.height; + this.renderMinY = renderMinY; + this.renderMaxY = renderMaxY; + this.tilesX = parent.tilesX; + this.tilesY = parent.tilesY; + this.viewportCount = parent.viewportCount; + this.numRenderSegments = parent.numRenderSegments; + this.centerCoordinate = parent.centerCoordinate; + this.projectionScale = parent.projectionScale; + this.stereoViewportWidth = parent.stereoViewportWidth; + this.stereoViewportOffsetX = parent.stereoViewportOffsetX; + this.stereoEye = parent.stereoEye; + this.renderMinX = parent.renderMinX; + this.renderMaxX = parent.renderMaxX; + this.bufferedImage = parent.bufferedImage; + this.pixels = parent.pixels; + this.depth = parent.depth; + this.graphics = parent.graphics; + this.vertexSlot = parent.vertexSlot; + this.nearPlaneDistance = parent.nearPlaneDistance; + this.developerTools = parent.developerTools; + this.debugLogBuffer = parent.debugLogBuffer; + this.lightingManager = parent.lightingManager; + this.occlusionPyramid = parent.occlusionPyramid; + this.segmentGraphics = null; + } + + /** + * Creates an independent pass context for one pipeline pass (one eye + * in stereo): shares the frame's pixel buffer, graphics and services, + * but owns the per-pass projection fields (center, scale, stereo + * viewport, slot, frame/cycle stamps). The next pass's setup writes + * to its own copy, so it cannot disturb this pass's in-flight + * transform chunks or its asynchronous sort/bin/paint continuation. + * + * @param parent the frame rendering context to copy from + */ + public RenderingContext(final RenderingContext parent) { + this.width = parent.width; + this.height = parent.height; + this.renderMinY = parent.renderMinY; + this.renderMaxY = parent.renderMaxY; + this.tilesX = parent.tilesX; + this.tilesY = parent.tilesY; + this.viewportCount = parent.viewportCount; + this.numRenderSegments = parent.numRenderSegments; + this.centerCoordinate = new Point2D(parent.centerCoordinate.x, parent.centerCoordinate.y); + this.projectionScale = parent.projectionScale; + this.stereoViewportWidth = parent.stereoViewportWidth; + this.stereoViewportOffsetX = parent.stereoViewportOffsetX; + this.stereoEye = parent.stereoEye; + this.renderMinX = parent.renderMinX; + this.renderMaxX = parent.renderMaxX; + this.bufferedImage = parent.bufferedImage; + this.pixels = parent.pixels; + this.depth = parent.depth; + this.graphics = parent.graphics; + this.vertexSlot = parent.vertexSlot; + this.nearPlaneDistance = parent.nearPlaneDistance; + this.frameNumber = parent.frameNumber; + this.transformCycleId = parent.transformCycleId; + this.transformExecutor = parent.transformExecutor; + this.developerTools = parent.developerTools; + this.debugLogBuffer = parent.debugLogBuffer; + this.lightingManager = parent.lightingManager; + this.cullingStatistics = parent.cullingStatistics; + this.occlusionPyramid = parent.occlusionPyramid; + this.subpixelCullingThreshold = parent.subpixelCullingThreshold; + this.subpixelCullingEpoch = parent.subpixelCullingEpoch; + this.setMouseEvent(parent.getMouseEvent()); + // Share the pre-clipped per-tile graphics: glyph rendering + // (user-facing text) draws through them by segment index. Null + // here made every glyph paint die with an NPE mid-tile (broken + // tiles whenever text faced the reader). + this.segmentGraphics = parent.segmentGraphics; + // frustum stays null: created fresh per pass in transformShapesBegin + } + + /** + * Resets per-frame state in preparation for rendering a new frame. + * Increments the frame number and clears the mouse event state. + */ + public void prepareForNewFrameRendering() { + frameNumber++; + mouseEvent = null; + currentObjectUnderMouseCursor = null; + } + + /** + * Creates Graphics2D contexts for each render segment, pre-clipped to + * its tile rectangle. Segment index layout: viewport v, tile row ty, + * tile column tx -> v * tilesX * tilesY + ty * tilesX + tx. + * + * @return array of Graphics2D objects, one per segment + */ + private Graphics2D[] createSegmentGraphics() { + final Graphics2D[] contexts = new Graphics2D[numRenderSegments]; + final int viewportWidth = width / viewportCount; + final int tileW = viewportWidth / tilesX; + final int tileH = height / tilesY; + + for (int v = 0; v < viewportCount; v++) { + final int viewportX = v * viewportWidth; + for (int ty = 0; ty < tilesY; ty++) { + final int minY = ty * tileH; + final int maxY = (ty == tilesY - 1) ? height : (ty + 1) * tileH; + for (int tx = 0; tx < tilesX; tx++) { + final int minX = viewportX + tx * tileW; + final int maxX = (tx == tilesX - 1) + ? viewportX + viewportWidth : minX + tileW; + + final Graphics2D g = bufferedImage.createGraphics(); + g.setClip(minX, minY, maxX - minX, maxY - minY); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + contexts[v * tilesX * tilesY + ty * tilesX + tx] = g; + } + } + } + + return contexts; + } + + /** + * Returns the backing image whose pixel buffer the rasterizer paints into. + * + *

Exposed for headless rendering: after a transform/sort/paint pass the + * image holds the finished frame and can be saved or compared directly.

+ * + * @return the backing buffered image + */ + public BufferedImage getImage() { + return bufferedImage; + } + + /** + * Returns the Graphics2D context for a specific render segment. + * Each segment's Graphics2D is pre-clipped to its Y bounds. + * + * @param segmentIndex the segment index (0 to numRenderSegments-1) + * @return the Graphics2D for that segment + * @throws NullPointerException if called on a segment view (not the main context) + */ + public Graphics2D getSegmentGraphics(final int segmentIndex) { + return segmentGraphics[segmentIndex]; + } + + /** + * Disposes all Graphics2D resources associated with this context. + * Should be called when the context is no longer needed (e.g., on resize). + */ + public void dispose() { + if (segmentGraphics != null) { + for (final Graphics2D g : segmentGraphics) { + if (g != null) { + g.dispose(); + } + } + } + if (graphics != null) { + graphics.dispose(); + } + } + + /** + * Executes a graphics operation in a thread-safe manner. + * This must be used for all Graphics2D operations (text, lines, etc.) + * during multi-threaded rendering. + * + * @param operation the graphics operation to execute + */ + public void executeWithGraphics(final Consumer operation) { + synchronized (graphics) { + operation.accept(graphics); + } + } + + /** + * Returns the pending mouse event for this frame, or {@code null} if none. + * + * @return the mouse event to process, or {@code null} + */ + public MouseEvent getMouseEvent() { + return mouseEvent; + } + + /** + * Sets the mouse event to be processed during this frame's rendering. + * + * @param mouseEvent the mouse event with position and button information + */ + public void setMouseEvent(MouseEvent mouseEvent) { + this.mouseEvent = mouseEvent; + } + + /** + * Called when given object was detected under mouse cursor, while processing {@link #mouseEvent}. + * Because objects are rendered back to front. The last method caller will set the top-most object, if + * there are multiple objects under mouse cursor. + * + * @param currentObjectUnderMouseCursor the object that is currently under the mouse cursor + */ + public synchronized void setCurrentObjectUnderMouseCursor(MouseInteractionController currentObjectUnderMouseCursor) { + setCurrentObjectUnderMouseCursor(currentObjectUnderMouseCursor, + Double.NaN, Double.NaN); + } + + /** + * Called when given object was detected under mouse cursor, with the + * texture coordinates of the hit point (for textured shapes). + * + * @param currentObjectUnderMouseCursor the object under the mouse cursor + * @param textureU texture-space X of the hit point in primary-texture pixels + * @param textureV texture-space Y of the hit point in primary-texture pixels + */ + public synchronized void setCurrentObjectUnderMouseCursor( + final MouseInteractionController currentObjectUnderMouseCursor, + final double textureU, final double textureV) { + this.currentObjectUnderMouseCursor = currentObjectUnderMouseCursor; + this.currentMouseTextureU = textureU; + this.currentMouseTextureV = textureV; + } + + /** + * Returns the current object under the mouse cursor. + * Used by segment rendering to collect mouse results. + * + * @return the current object under mouse cursor, or null + */ + public synchronized MouseInteractionController getCurrentObjectUnderMouseCursor() { + return currentObjectUnderMouseCursor; + } + + /** + * Handles mouse events for components and returns whether a view repaint is needed. + * + * @return {@code true} if view update is needed as a consequence of this mouse event + */ + public boolean handlePossibleComponentMouseEvent() { + if (mouseEvent == null) return false; + + boolean viewRepaintNeeded = false; + + if (objectPreviouslyUnderMouseCursor != currentObjectUnderMouseCursor) { + // Mouse cursor has just entered or left component. + viewRepaintNeeded = objectPreviouslyUnderMouseCursor != null && objectPreviouslyUnderMouseCursor.mouseExited(); + viewRepaintNeeded |= currentObjectUnderMouseCursor != null && currentObjectUnderMouseCursor.mouseEntered(); + objectPreviouslyUnderMouseCursor = currentObjectUnderMouseCursor; + } + + if (mouseEvent.button != 0 && currentObjectUnderMouseCursor != null) { + // Mouse button was clicked on some component. + viewRepaintNeeded |= currentObjectUnderMouseCursor.mouseClicked( + mouseEvent.button, currentMouseTextureU, currentMouseTextureV); + } else if (currentObjectUnderMouseCursor != null) + // hover: let the component track the pointer position + viewRepaintNeeded |= currentObjectUnderMouseCursor.mouseHover( + currentMouseTextureU, currentMouseTextureV); + + return viewRepaintNeeded; + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/SegmentRenderingContext.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/SegmentRenderingContext.java new file mode 100644 index 0000000..a7cc17b --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/SegmentRenderingContext.java @@ -0,0 +1,105 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent; +import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController; + +import java.awt.*; +import java.util.function.Consumer; + +/** + * A view of a RenderingContext for rendering a horizontal screen segment. + * + *

This class wraps a parent RenderingContext and provides its own Y-bounds + * for multi-threaded rendering. All operations delegate to the parent context, + * but with segment-specific Y bounds for pixel operations.

+ * + *

Mouse tracking is local to each segment and must be combined after all + * segments complete rendering.

+ * + * @see RenderingContext + */ +public class SegmentRenderingContext extends RenderingContext { + + private final RenderingContext parent; + private final int segmentIndex; + private MouseInteractionController segmentMouseHit; + private double segmentMouseHitU = Double.NaN; + private double segmentMouseHitV = Double.NaN; + + /** + * Creates a segment view of a parent rendering context. + * + * @param parent the parent rendering context to delegate to + * @param renderMinY minimum Y coordinate (inclusive) for this segment + * @param renderMaxY maximum Y coordinate (exclusive) for this segment + * @param segmentIndex the index of this segment (0 to numRenderSegments-1) + */ + public SegmentRenderingContext(final RenderingContext parent, + final int renderMinY, final int renderMaxY, + final int segmentIndex) { + super(parent, renderMinY, renderMaxY); + this.parent = parent; + this.segmentIndex = segmentIndex; + } + + @Override + public void executeWithGraphics(final Consumer operation) { + operation.accept(parent.getSegmentGraphics(segmentIndex)); + } + + @Override + public MouseEvent getMouseEvent() { + return parent.getMouseEvent(); + } + + @Override + public void setMouseEvent(final MouseEvent mouseEvent) { + parent.setMouseEvent(mouseEvent); + } + + @Override + public synchronized void setCurrentObjectUnderMouseCursor(final MouseInteractionController controller) { + setCurrentObjectUnderMouseCursor(controller, Double.NaN, Double.NaN); + } + + @Override + public synchronized void setCurrentObjectUnderMouseCursor( + final MouseInteractionController controller, + final double textureU, final double textureV) { + this.segmentMouseHit = controller; + this.segmentMouseHitU = textureU; + this.segmentMouseHitV = textureV; + } + + /** + * Returns the mouse hit detected in this segment. + * + * @return the MouseInteractionController that was under the mouse in this segment, or null + */ + public MouseInteractionController getSegmentMouseHit() { + return segmentMouseHit; + } + + /** + * Texture-space X of the hit point (primary texture pixels), NaN if none. + */ + public double getSegmentMouseHitU() { + return segmentMouseHitU; + } + + /** + * Texture-space Y of the hit point (primary texture pixels), NaN if none. + */ + public double getSegmentMouseHitV() { + return segmentMouseHitV; + } + + @Override + public synchronized MouseInteractionController getCurrentObjectUnderMouseCursor() { + return segmentMouseHit; + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/StereoEye.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/StereoEye.java new file mode 100644 index 0000000..aa9c99f --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/StereoEye.java @@ -0,0 +1,19 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +/** + * Identifies which eye is being rendered in stereoscopic mode. + * + * @see RenderingContext#stereoEye + */ +public enum StereoEye { + /** Normal single-view rendering (no stereo). */ + NONE, + /** Left eye view in side-by-side stereo mode. */ + LEFT, + /** Right eye view in side-by-side stereo mode. */ + RIGHT +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/TextPointer.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/TextPointer.java new file mode 100755 index 0000000..fc057a5 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/TextPointer.java @@ -0,0 +1,123 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +import static java.lang.Integer.compare; + +/** + * A pointer to a character in a text using row and column. + *

+ * It can be used to represent a cursor position in a text. + * Also, it can be used to represent beginning and end of a selection. + */ +public class TextPointer implements Comparable { + + /** + * The row of the character. Starts from 0. + */ + public int row; + + /** + * The column of the character. Starts from 0. + */ + public int column; + + /** + * Creates a text pointer at position (0, 0). + */ + public TextPointer() { + this(0, 0); + } + + /** + * Creates a text pointer at the specified row and column. + * + * @param row the row index (0-based) + * @param column the column index (0-based) + */ + public TextPointer(final int row, final int column) { + this.row = row; + this.column = column; + } + + /** + * Creates a text pointer by copying another text pointer. + * + * @param parent the text pointer to copy + */ + public TextPointer(final TextPointer parent) { + this(parent.row, parent.column); + } + + @Override + public boolean equals(final Object o) { + if (o == null) return false; + + return o instanceof TextPointer && compareTo((TextPointer) o) == 0; + } + + @Override + public int hashCode() { + int result = row; + result = 31 * result + column; + return result; + } + + /** + * Compares this pointer to another pointer. + * + * @param textPointer The pointer to compare to. + * @return

    + *
  • -1 if this pointer is smaller than the argument pointer.
  • + *
  • 0 if they are equal.
  • + *
  • 1 if this pointer is bigger than the argument pointer.
  • + *
+ */ + @Override + public int compareTo(final TextPointer textPointer) { + + if (row < textPointer.row) + return -1; + if (row > textPointer.row) + return 1; + + return compare(column, textPointer.column); + } + + /** + * Checks if this pointer is between the argument pointers. + *

+ * This pointer is considered to be between the pointers if it is bigger or equal to the start pointer + * and smaller than the end pointer. + * + * @param start The start pointer. + * @param end The end pointer. + * @return True if this pointer is between the specified pointers. + */ + public boolean isBetween(final TextPointer start, final TextPointer end) { + + if (start == null) + return false; + + if (end == null) + return false; + + // Make sure that start is smaller than end. + TextPointer smaller; + TextPointer bigger; + + if (end.compareTo(start) >= 0) { + smaller = start; + bigger = end; + } else { + smaller = end; + bigger = start; + } + + // Check if this pointer is between the specified pointers. + return (compareTo(smaller) >= 0) && (bigger.compareTo(this) > 0); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ThreadActivityRecorder.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ThreadActivityRecorder.java new file mode 100644 index 0000000..eff1e39 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ThreadActivityRecorder.java @@ -0,0 +1,212 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Low-overhead recorder of per-thread work intervals for the thread + * timeline display in the Developer Tools window. + * + *

Task submission sites (transform chunks, paint tiles, tile binning) + * and render-thread phases (orchestration, waiting, blit) record their + * [start, end) intervals here when enabled. The timeline component paints + * each thread as a row, time on the X axis, the interval kind as color — + * the software-renderer equivalent of a GPU frame-profiler occupancy + * view. Idle time is simply the absence of intervals (black).

+ * + *

Overhead when disabled: one volatile read per task. When enabled: + * two {@code System.nanoTime()} calls, one atomic increment and four + * array stores per task (~100 ns), negligible at hundreds of tasks per + * frame.

+ * + *

Frame parity distinguishes "current frame" work from "next frame" + * work in the colors: transform/paint/binning kinds come in an even and + * an odd variant, selected by the parity that was current when the task + * was SUBMITTED (captured at task creation, so overlapping frames keep + * their own color).

+ */ +public final class ThreadActivityRecorder { + + /** Kind base: vertex transform chunk (add frame parity 0..2). */ + public static final int KIND_TRANSFORM = 0; + /** Kind base: paint tile task (add frame parity 0..2). */ + public static final int KIND_PAINT = 3; + /** Kind base: tile binning task (add frame parity 0..2). */ + public static final int KIND_BIN = 6; + /** Kind: render thread orchestration (tree walk, submission, merges). */ + public static final int KIND_RENDER = 9; + /** Kind: render thread blocked waiting for worker tasks. */ + public static final int KIND_AWAIT = 10; + /** Kind: render thread blitting the finished frame to screen. */ + public static final int KIND_BLIT = 11; + /** Kind: a pass's asynchronous continuation (drain, sort, bin, paint submission). */ + public static final int KIND_PREP = 12; + /** Kind: continuation sub-phase: draining and merging transform chunks. */ + public static final int KIND_DRAIN = 13; + /** Kind: continuation sub-phase: depth sort. */ + public static final int KIND_SORT = 14; + + /** Number of distinct kinds (three parities each for transform/paint/bin, plus 9..14). */ + public static final int KIND_COUNT = 15; + + private static final int CAPACITY = 1 << 18; + private static final int MASK = CAPACITY - 1; + + private static final long[] starts = new long[CAPACITY]; + private static final long[] ends = new long[CAPACITY]; + private static final byte[] kinds = new byte[CAPACITY]; + private static final byte[] rows = new byte[CAPACITY]; + private static final AtomicInteger cursor = new AtomicInteger(); + + private static volatile boolean enabled = false; + private static volatile int frameParity = 0; + + private static final ConcurrentHashMap rowByThreadName = new ConcurrentHashMap<>(); + private static final CopyOnWriteArrayList rowNames = new CopyOnWriteArrayList<>(); + private static final AtomicInteger nextRow = new AtomicInteger(); + + private ThreadActivityRecorder() { + } + + /** + * @return true when recording is active (checked by task submission sites) + */ + public static boolean isEnabled() { + return enabled; + } + + /** + * Enables or disables recording. Enabling starts with a clean buffer + * and fresh thread-row assignment. + * + * @param value true to start recording + */ + public static void setEnabled(final boolean value) { + if (value && !enabled) { + clear(); + } + enabled = value; + } + + /** + * Drops all recorded intervals and thread-row assignments. + */ + public static void clear() { + cursor.set(0); + rowByThreadName.clear(); + rowNames.clear(); + nextRow.set(0); + // starts[]==0 marks an empty slot for the timeline sweep + java.util.Arrays.fill(starts, 0L); + } + + /** + * Sets the parity (0/1) of the frame currently being prepared. + * Called by the render thread at the start of each frame; task + * submission sites capture it into their tasks so overlapping frames + * keep distinct colors. + * + * @param parity frame parity, 0 or 1 + */ + public static void setFrameParity(final int parity) { + frameParity = parity; + } + + /** + * @return parity of the frame currently being prepared + */ + public static int frameParity() { + return frameParity; + } + + /** + * Records one work interval on the calling thread. + * + * @param kind interval kind (one of the KIND_* bases, plus parity + * for transform/paint/binning) + * @param t0 interval start, from {@link System#nanoTime()} + * @param t1 interval end, from {@link System#nanoTime()} + */ + public static void record(final int kind, final long t0, final long t1) { + // Rows are keyed by thread NAME, not Thread object: after a + // stop()/start() cycle the executor is recreated with fresh + // threads under the same names, and they must reuse the same + // rows — otherwise the timeline fills with dead threads' rows + // and pushes the live workers below the visible area. + final String threadName = Thread.currentThread().getName(); + Integer row = rowByThreadName.get(threadName); + if (row == null) { + row = rowByThreadName.computeIfAbsent(threadName, t -> { + final int r = nextRow.getAndIncrement(); + rowNames.add(t); + return r; + }); + } + if (row > 127) { + return; + } + final int i = cursor.getAndIncrement() & MASK; + starts[i] = t0; + ends[i] = t1; + kinds[i] = (byte) kind; + rows[i] = (byte) (int) row; + } + + // ---- Snapshot access for the timeline component ---- + + /** @return total number of intervals recorded since the last clear */ + public static int cursor() { + return cursor.get(); + } + + /** @return ring buffer capacity */ + public static int capacity() { + return CAPACITY; + } + + /** @return interval start array (index space of the ring buffer) */ + public static long[] starts() { + return starts; + } + + /** @return interval end array (index space of the ring buffer) */ + public static long[] ends() { + return ends; + } + + /** @return interval kind array (index space of the ring buffer) */ + public static byte[] kinds() { + return kinds; + } + + /** @return interval thread-row array (index space of the ring buffer) */ + public static byte[] rows() { + return rows; + } + + /** @return number of distinct thread rows seen so far */ + public static int rowCount() { + return nextRow.get(); + } + + /** + * @param row thread row index + * @return display name for the row ("render" for the render thread, + * otherwise the thread name with the e3d- prefix stripped) + */ + public static String rowName(final int row) { + if (row >= rowNames.size()) { + return "?"; + } + final String name = rowNames.get(row); + if ("e3d-render".equals(name)) { + return "render"; + } + return name.startsWith("e3d-") ? name.substring(4) : name; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ThreadTimelineComponent.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ThreadTimelineComponent.java new file mode 100644 index 0000000..aafa7ae --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ThreadTimelineComponent.java @@ -0,0 +1,322 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.AdjustmentEvent; +import java.awt.event.AdjustmentListener; +import java.awt.event.MouseWheelEvent; +import java.awt.event.MouseWheelListener; + +/** + * Per-thread activity timeline for the Developer Tools window — the + * software-renderer equivalent of a GPU frame profiler's occupancy view. + * + *

One row per worker thread, plus the render and present threads + * pinned to the top. Worker rows are labeled CPU 01..N in a stable + * order. Time on the X axis, interval kind as color. Black gaps are + * idle time — the spots + * where a core had no work, which is what this view exists to find. In a + * perfectly pipelined renderer every worker row is solid: when transform + * of frame N+1 (yellow-green) starts appearing inside paint of frame N + * (blue), the pipeline is overlapping as intended.

+ * + *

While recording, the view shows a live-scrolling window of the last + * ~100 ms. After Record is toggled off the capture freezes and can be + * scrolled back through the whole retained history (the ring buffer + * keeps roughly the last 10+ seconds at typical task rates) with the + * scrollbar or the mouse wheel.

+ * + *

Reads the {@link ThreadActivityRecorder} ring buffer directly on + * repaint; recording itself is toggled from the parent panel.

+ */ +public class ThreadTimelineComponent extends JComponent { + + /** Time window shown, in nanoseconds. Adjustable with ctrl+wheel. */ + private long windowNanos = 100_000_000L; + + /** Minimum and maximum zoom window, nanoseconds. */ + private static final long MIN_WINDOW_NANOS = 5_000_000L; + private static final long MAX_WINDOW_NANOS = 2_000_000_000L; + + private static final int ROW_HEIGHT = 11; + private static final int LABEL_WIDTH = 64; + private static final int LEGEND_HEIGHT = 16; + /** Rows the component reserves height for, so all workers + render fit. */ + private static final int RESERVED_ROWS = 26; + + /** Colors per recorded kind: three frame parities each for transform/paint/bin, then 9..11. */ + private static final Color[] KIND_COLORS = { + new Color(0x2E, 0xCC, 0x40), // transform, frame%3=0 — green + new Color(0xB8, 0xD9, 0x00), // transform, frame%3=1 — yellow-green + new Color(0x6B, 0x8E, 0x23), // transform, frame%3=2 — olive + new Color(0x00, 0x74, 0xD9), // paint, frame%3=0 — blue + new Color(0xF0, 0x12, 0xBE), // paint, frame%3=1 — magenta + new Color(0xB1, 0x0D, 0xC9), // paint, frame%3=2 — purple + new Color(0x39, 0xCC, 0xCC), // binning, frame%3=0 — cyan + new Color(0x00, 0x8B, 0x8B), // binning, frame%3=1 — dark cyan + new Color(0x00, 0x70, 0x70), // binning, frame%3=2 — teal + new Color(0xA0, 0xA0, 0xA0), // render-thread serial — gray + new Color(0x8B, 0x00, 0x00), // render-thread waiting— dark red + new Color(0xFF, 0xFF, 0xFF), // blit — white + new Color(0xFF, 0x85, 0x1B), // continuation (drain/sort/bin) — orange + new Color(0x8B, 0x45, 0x13), // drain+merge — brown + new Color(0xFF, 0xD7, 0x00), // depth sort — gold + }; + + private static final String[] LEGEND = { + "transform f0", "transform f1", "transform f2", + "paint f0", "paint f1", "paint f2", + "bin f0", "bin f1", "bin f2", + "render serial", "blocked", "blit", "sort+bin", "drain", "sort", + }; + + /** + * Scrollbar for navigating a frozen capture, in milliseconds scrolled + * back from the latest recorded interval. Owned here so the component + * can keep the model in sync with the capture length; the parent + * panel adds it below the timeline. + */ + public final JScrollBar scrollBar = new JScrollBar(JScrollBar.HORIZONTAL, 0, 100, 0, 100); + + /** + * How far back from the latest recorded interval the window ends, + * nanoseconds. Always 0 (live edge) while recording; user-adjustable + * on a frozen capture. + */ + private long scrollBackNanos = 0; + + /** True while the scrollbar model is being updated programmatically. */ + private boolean updatingScrollBar = false; + + public ThreadTimelineComponent() { + setBackground(Color.BLACK); + + scrollBar.addAdjustmentListener(new AdjustmentListener() { + @Override + public void adjustmentValueChanged(final AdjustmentEvent e) { + if (!updatingScrollBar) { + scrollBackNanos = scrollBar.getValue() * 1_000_000L; + repaint(); + } + } + }); + + addMouseWheelListener(new MouseWheelListener() { + @Override + public void mouseWheelMoved(final MouseWheelEvent e) { + if (e.isControlDown()) { + // Zoom around the current window, x1.25 per notch + for (int i = 0; i < Math.abs(e.getWheelRotation()); i++) { + windowNanos = e.getWheelRotation() < 0 + ? Math.max(MIN_WINDOW_NANOS, windowNanos * 4 / 5) + : Math.min(MAX_WINDOW_NANOS, windowNanos * 5 / 4); + } + } else { + if (ThreadActivityRecorder.isEnabled()) { + return; // live mode: nothing to scroll + } + scrollBackNanos = Math.max(0, scrollBackNanos + + e.getWheelRotation() * Math.max(1_000_000L, windowNanos / 10)); + } + repaint(); + } + }); + } + + @Override + public Dimension getPreferredSize() { + // Reserve full height up front: worker rows appear lazily as the + // pool starts, after the window has already been packed + return new Dimension(560, LEGEND_HEIGHT + RESERVED_ROWS * ROW_HEIGHT + 4); + } + + @Override + protected void paintComponent(final Graphics graphics) { + super.paintComponent(graphics); + final Graphics2D g = (Graphics2D) graphics; + final int width = getWidth(); + final int trackWidth = width - LABEL_WIDTH; + + g.setColor(Color.BLACK); + g.fillRect(0, 0, width, getHeight()); + + // Legend + g.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 9)); + int legendX = 2; + for (int kind = 0; kind < LEGEND.length; kind++) { + g.setColor(KIND_COLORS[kind]); + g.fillRect(legendX, 3, 8, 8); + g.setColor(Color.LIGHT_GRAY); + g.drawString(LEGEND[kind], legendX + 10, 11); + legendX += 10 + g.getFontMetrics().stringWidth(LEGEND[kind]) + 10; + } + + final boolean recording = ThreadActivityRecorder.isEnabled(); + if (!recording && ThreadActivityRecorder.cursor() == 0) { + g.setColor(Color.GRAY); + g.drawString("recording off — press Record to capture thread activity", + LABEL_WIDTH + 8, LEGEND_HEIGHT + ROW_HEIGHT); + syncScrollBar(0, 0, true); + return; + } + + final long[] starts = ThreadActivityRecorder.starts(); + final long[] ends = ThreadActivityRecorder.ends(); + final byte[] kinds = ThreadActivityRecorder.kinds(); + final byte[] rows = ThreadActivityRecorder.rows(); + final int capacity = ThreadActivityRecorder.capacity(); + + // Capture range: oldest retained interval to latest one + long latest = 0; + long oldest = Long.MAX_VALUE; + for (int i = 0; i < capacity; i++) { + if (starts[i] != 0) { + if (ends[i] > latest) { + latest = ends[i]; + } + if (starts[i] < oldest) { + oldest = starts[i]; + } + } + } + if (latest == 0) { + return; + } + if (oldest > latest - windowNanos) { + oldest = latest - windowNanos; + } + + // Live edge while recording; frozen offset afterwards + if (recording) { + scrollBackNanos = 0; + } + final long maxScrollBack = latest - oldest - windowNanos; + scrollBackNanos = Math.max(0, Math.min(scrollBackNanos, Math.max(0, maxScrollBack))); + + final long windowEnd = latest - scrollBackNanos; + final long windowStart = windowEnd - windowNanos; + final double nanosPerPixel = (double) windowNanos / trackWidth; + + final int rowCount = ThreadActivityRecorder.rowCount(); + + // Visual row order: "render" and "present" pinned to the top + // (they answer "where is the serial time" and "where is the + // display time"), workers below in recorder-row order. Recorder + // rows are assigned once per thread name, so this order is + // stable for the whole capture. + int renderRow = -1; + int presentRow = -1; + for (int row = 0; row < rowCount; row++) { + final String name = ThreadActivityRecorder.rowName(row); + if ("render".equals(name)) { + renderRow = row; + } else if ("present".equals(name)) { + presentRow = row; + } + } + final int[] visualOf = new int[rowCount]; + int nextVisual = 0; + if (renderRow >= 0) { + visualOf[renderRow] = nextVisual++; + } + if (presentRow >= 0) { + visualOf[presentRow] = nextVisual++; + } + for (int row = 0; row < rowCount; row++) { + if (row != renderRow && row != presentRow) { + visualOf[row] = nextVisual++; + } + } + + // Row labels and separators. Workers get sequential CPU numbers + // by visual position — the thread-name suffix (worker-27 etc.) + // is just a pool counter and carries no meaning. + int cpuNumber = 0; + final String[] labels = new String[rowCount]; + for (int row = 0; row < rowCount; row++) { + if (row == renderRow) { + labels[row] = "render"; + } else if (row == presentRow) { + labels[row] = "present"; + } else { + labels[row] = String.format("CPU %02d", ++cpuNumber); + } + } + g.setColor(Color.DARK_GRAY); + for (int row = 0; row < rowCount; row++) { + final int y = LEGEND_HEIGHT + visualOf[row] * ROW_HEIGHT; + g.drawLine(LABEL_WIDTH, y + ROW_HEIGHT - 1, width, y + ROW_HEIGHT - 1); + g.setColor(Color.GRAY); + g.drawString(labels[row], 4, y + ROW_HEIGHT - 3); + g.setColor(Color.DARK_GRAY); + } + + // Intervals + for (int i = 0; i < capacity; i++) { + final long t0 = starts[i]; + if (t0 == 0 || ends[i] < windowStart || t0 > windowEnd) { + continue; + } + final int kind = kinds[i]; + if (kind < 0 || kind >= KIND_COLORS.length) { + continue; + } + final int x0 = LABEL_WIDTH + (int) ((Math.max(t0, windowStart) - windowStart) / nanosPerPixel); + final int x1 = LABEL_WIDTH + (int) ((Math.min(ends[i], windowEnd) - windowStart) / nanosPerPixel); + g.setColor(KIND_COLORS[kind]); + g.fillRect(x0, LEGEND_HEIGHT + visualOf[rows[i]] * ROW_HEIGHT + 1, + Math.max(1, x1 - x0), ROW_HEIGHT - 2); + } + + // Time grid: pick a tick spacing that keeps ~10 ticks in view + long tickSpacing = 1_000_000L; // 1 ms + while (windowNanos / tickSpacing > 20) { + tickSpacing *= 10; + } + while (windowNanos / tickSpacing < 5 && tickSpacing > 100_000L) { + tickSpacing /= 10; + } + g.setColor(new Color(40, 40, 40)); + final long firstTick = (windowStart / tickSpacing + 1) * tickSpacing; + for (long tick = firstTick; tick < windowEnd; tick += tickSpacing) { + final int x = LABEL_WIDTH + (int) ((tick - windowStart) / nanosPerPixel); + g.drawLine(x, LEGEND_HEIGHT, x, LEGEND_HEIGHT + rowCount * ROW_HEIGHT); + } + + // Window size + frozen-offset readout, bottom right of the track + g.setColor(Color.YELLOW); + final String readout = (windowNanos >= 1_000_000_000L + ? String.format("%.1f s", windowNanos / 1e9) + : String.format("%.0f ms", windowNanos / 1e6)) + + (recording ? " live" : String.format(" -%.2f s", scrollBackNanos / 1e9)); + g.drawString(readout, width - g.getFontMetrics().stringWidth(readout) - 6, + LEGEND_HEIGHT + rowCount * ROW_HEIGHT - 4); + + syncScrollBar((int) (scrollBackNanos / 1_000_000L), + (int) (Math.max(0, maxScrollBack) / 1_000_000L) + (int) (windowNanos / 1_000_000L), + recording); + } + + /** + * Pushes the current scroll state into the scrollbar model without + * feeding back into the adjustment listener. + * + * @param valueMs current scroll-back offset in milliseconds + * @param maxMs maximum scroll-back offset plus window size + * @param disabled true to grey out the scrollbar (live recording) + */ + private void syncScrollBar(final int valueMs, final int maxMs, final boolean disabled) { + final int visibleMs = Math.max(1, (int) (windowNanos / 1_000_000L)); + updatingScrollBar = true; + try { + scrollBar.setEnabled(!disabled && maxMs > visibleMs); + scrollBar.setValues(Math.min(valueMs, maxMs), visibleMs, 0, Math.max(visibleMs, maxMs)); + } finally { + updatingScrollBar = false; + } + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewFrame.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewFrame.java new file mode 100755 index 0000000..2dfe340 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewFrame.java @@ -0,0 +1,345 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.ComponentEvent; +import java.awt.event.ComponentListener; +import java.awt.event.WindowEvent; +import java.awt.event.WindowListener; + +/** + * Convenience window (JFrame) that creates and hosts a {@link ViewPanel} for 3D rendering. + * + *

This is the simplest way to get a 3D view up and running. The frame starts + * maximized, enforces a minimum size of 400x400, and handles window lifecycle + * events (minimizing, restoring, closing) automatically.

+ * + *

Quick start:

+ *
{@code
+ * // Create a window with a 3D view
+ * ViewFrame frame = new ViewFrame();
+ *
+ * // Access the view panel to add shapes and configure the scene
+ * ViewPanel viewPanel = frame.getViewPanel();
+ * viewPanel.getRootShapeCollection().addShape(
+ *     new WireframeCube(new Point3D(0, 0, 200), 50,
+ *         new LineAppearance(5, Color.GREEN))
+ * );
+ *
+ * // To close programmatically:
+ * frame.exit();
+ * }
+ * + * @see ViewPanel the embedded 3D rendering panel + */ +public class ViewFrame extends JFrame implements WindowListener { + + private static final long serialVersionUID = -7037635097739548470L; + + /** The embedded 3D view panel. */ + private final ViewPanel viewPanel; + + /** Whether the frame is currently in fullscreen exclusive mode. */ + private boolean fullscreen = false; + + /** Saved window bounds before entering fullscreen, used to restore on exit. */ + private Rectangle previousBounds; + + /** Saved extended state before entering fullscreen. */ + private int previousExtendedState; + + /** + * Creates a new maximized window with a 3D view. + */ + public ViewFrame() { + this("3D engine", -1, -1, true); + } + + /** + * Creates a new maximized window with a 3D view and custom title. + * + * @param title the window title to display + */ + public ViewFrame(final String title) { + this(title, -1, -1, true); + } + + /** + * Creates a new window with a 3D view at the specified size. + * + * @param width window width in pixels, or -1 for default + * @param height window height in pixels, or -1 for default + */ + public ViewFrame(final int width, final int height) { + this("3D engine", width, height, false); + } + + /** + * Creates a new window with a 3D view at the specified size with a custom title. + * + * @param title the window title to display + * @param width window width in pixels, or -1 for default + * @param height window height in pixels, or -1 for default + */ + public ViewFrame(final String title, final int width, final int height) { + this(title, width, height, false); + } + + private ViewFrame(final String title, final int width, final int height, final boolean maximize) { + setTitle(title); + + addWindowListener(new java.awt.event.WindowAdapter() { + @Override + public void windowClosing(final java.awt.event.WindowEvent e) { + exit(); + } + }); + + viewPanel = new ViewPanel(); + + add(getViewPanel()); + + if (width > 0 && height > 0) { + setSize(width, height); + } else { + setSize(800, 600); + } + + if (maximize) { + setExtendedState(JFrame.MAXIMIZED_BOTH); + } + setVisible(true); + validate(); + + addResizeListener(); + addWindowListener(this); + } + + private void addResizeListener() { + addComponentListener(new ComponentListener() { + // This method is called after the component's size changes + @Override + public void componentHidden(final ComponentEvent e) { + } + + @Override + public void componentMoved(final ComponentEvent e) { + } + + @Override + public void componentResized(final ComponentEvent evt) { + + final Component c = (Component) evt.getSource(); + + // Get new size + final Dimension newSize = c.getSize(); + + boolean sizeFixed = false; + + if (newSize.width < 400) { + newSize.width = 400; + sizeFixed = true; + } + + if (newSize.height < 400) { + newSize.height = 400; + sizeFixed = true; + } + + if (sizeFixed) + setSize(newSize); + + } + + @Override + public void componentShown(final ComponentEvent e) { + viewPanel.repaintDuringNextViewUpdate(); + } + + }); + } + + /** + * Exit the application. + */ + public void exit() { + if (getViewPanel() != null) { + getViewPanel().stop(); + getViewPanel().setEnabled(false); + getViewPanel().setVisible(false); + } + dispose(); + } + + @Override + public java.awt.Dimension getPreferredSize() { + return new java.awt.Dimension(640, 480); + } + + /** + * Returns the embedded {@link ViewPanel} for adding shapes and configuring the scene. + * + * @return the view panel contained in this frame + */ + public ViewPanel getViewPanel() { + return viewPanel; + } + + /** + * Returns whether the frame is currently in fullscreen exclusive mode. + * + * @return {@code true} if the frame is in fullscreen exclusive mode + */ + public boolean isFullscreen() { + return fullscreen; + } + + /** + * Enters or exits fullscreen exclusive mode (FSEM). + * + *

Fullscreen always targets the screen the window is currently + * placed on (its {@link GraphicsConfiguration#getDevice()}), so a + * window dragged onto e.g. XR glasses goes fullscreen there, not on + * the primary monitor. The default screen device is only a fallback + * for a not-yet-displayed frame.

+ * + *

When entering fullscreen, the current window bounds and extended state are + * saved so they can be restored on exit. The frame is passed to the screen + * device as the fullscreen window, which automatically removes decorations and + * covers the entire screen. No explicit {@code setUndecorated()} call is needed — + * in fact, calling it would be harmful because it requires {@code dispose()} which + * destroys the Canvas and its BufferStrategy.

+ * + *

When exiting fullscreen, the frame is released from the screen device, + * decorations are restored automatically, and previous bounds/extended state + * are restored.

+ * + *

This method is idempotent: setting fullscreen to its current value + * is a no-op and returns {@code true}.

+ * + * @param on {@code true} to enter fullscreen, {@code false} to exit fullscreen + * @return {@code true} if the state changed or was already as requested, {@code false} + * if the transition could not be performed (e.g. unsupported graphics device) + */ + public boolean setFullscreen(final boolean on) { + if (this.fullscreen == on) + return true; + + GraphicsDevice device = null; + final GraphicsConfiguration configuration = getGraphicsConfiguration(); + if (configuration != null) + device = configuration.getDevice(); + if (device == null) + device = GraphicsEnvironment.getLocalGraphicsEnvironment() + .getDefaultScreenDevice(); + + if (!device.isFullScreenSupported()) + return false; + + try { + if (on) { + // Save current state before entering fullscreen + previousBounds = getBounds(); + previousExtendedState = getExtendedState(); + + // GraphicsDevice.setFullScreenWindow() automatically removes + // decorations when entering FSEM and restores them on exit. + // Do NOT call setUndecorated() here — it requires the frame to be + // non-displayable (dispose first) which destroys the Canvas and + // its BufferStrategy, breaking the render thread. + device.setFullScreenWindow(this); + fullscreen = true; + } else { + // Exit fullscreen exclusive mode. + // Decorations are restored automatically by the graphics device. + device.setFullScreenWindow(null); + + // Restore previous bounds and extended state + if (previousBounds != null) { + setBounds(previousBounds); + } + setExtendedState(previousExtendedState); + + fullscreen = false; + + // Trigger a repaint since the rendering surface changed + viewPanel.repaintDuringNextViewUpdate(); + } + } catch (final Exception e) { + // Something went wrong — revert if we were trying to enter + if (on) { + try { + device.setFullScreenWindow(null); + if (previousBounds != null) + setBounds(previousBounds); + setExtendedState(previousExtendedState); + } catch (final Exception ignored) { + } + } + return false; + } + + return true; + } + + /** + * Toggles between fullscreen exclusive mode and windowed mode. + * + *

Equivalent to {@code setFullscreen(!isFullscreen())}.

+ * + * @return {@code true} if the toggle succeeded + * @see #setFullscreen(boolean) + * @see #isFullscreen() + */ + public boolean toggleFullscreen() { + return setFullscreen(!fullscreen); + } + + @Override + public void windowActivated(final WindowEvent e) { + viewPanel.repaintDuringNextViewUpdate(); + viewPanel.requestFocus(); + } + + @Override + public void windowClosed(final WindowEvent e) { + } + + @Override + public void windowClosing(final WindowEvent e) { + } + + @Override + public void windowDeactivated(final WindowEvent e) { + } + + /** + * Repaint the view when the window is deiconified. + * + * Deiconified means that the window is restored from minimized state. + */ + @Override + public void windowDeiconified(final WindowEvent e) { + viewPanel.repaintDuringNextViewUpdate(); + } + + /** + * Do nothing when the window is iconified. + * + * Iconified means that the window is minimized. + * @param e the event to be processed + */ + @Override + public void windowIconified(final WindowEvent e) { + } + + @Override + public void windowOpened(final WindowEvent e) { + viewPanel.repaintDuringNextViewUpdate(); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java new file mode 100755 index 0000000..065f3b5 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java @@ -0,0 +1,1612 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +import eu.svjatoslav.aukio.e3d.diag.Diagnostics; +import eu.svjatoslav.aukio.e3d.diag.EngineConfig; +import eu.svjatoslav.aukio.e3d.diag.Telemetry; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.headtrack.HeadLookController; +import eu.svjatoslav.aukio.e3d.gui.headtrack.HeadTracker; +import eu.svjatoslav.aukio.e3d.gui.headtrack.HeadTrackingManager; +import eu.svjatoslav.aukio.e3d.gui.headtrack.RayNeoHid; +import eu.svjatoslav.aukio.e3d.gui.spacemouse.SpaceMouseManager; +import eu.svjatoslav.aukio.e3d.gui.spacemouse.SpaceNavigatorHid; +import eu.svjatoslav.aukio.e3d.gui.humaninput.InputManager; +import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardFocusStack; +import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent; +import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection; +import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager; + +import java.awt.*; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.awt.image.BufferStrategy; +import java.util.Arrays; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * AWT Canvas that provides a 3D rendering surface with built-in camera navigation. + * + *

{@code ViewPanel} is the primary entry point for embedding the Aukio 3D engine into + * a Java application. It manages the render loop, maintains a scene graph + * ({@link ShapeCollection}), and handles user input for camera navigation.

+ * + *

Uses {@link BufferStrategy} for efficient page-flipping and tear-free rendering.

+ * + *

Quick start - creating a 3D view in a window:

+ *
{@code
+ * // Option 1: Use ViewFrame (creates a maximized JFrame for you)
+ * ViewFrame frame = new ViewFrame();
+ * ViewPanel viewPanel = frame.getViewPanel();
+ *
+ * // Option 2: Embed ViewPanel in your own window
+ * JFrame frame = new JFrame("My 3D App");
+ * ViewPanel viewPanel = new ViewPanel();
+ * frame.add(viewPanel);
+ * frame.setSize(800, 600);
+ * frame.setVisible(true);
+ *
+ * // Add shapes to the scene
+ * ShapeCollection scene = viewPanel.getRootShapeCollection();
+ * scene.addShape(new WireframeCube(
+ *     new Point3D(0, 0, 200), 50,
+ *     new LineAppearance(5, Color.GREEN)
+ * ));
+ *
+ * // Position the camera
+ * viewPanel.getCamera().setLocation(new Point3D(0, 0, -100));
+ *
+ * // Listen for frame updates (e.g., for animations)
+ * viewPanel.addFrameListener((panel, deltaMs) -> {
+ *     // Called before each frame. Return true to force repaint.
+ *     return false;
+ * });
+ * }
+ * + *

Architecture:

+ *
    + *
  • A background render thread continuously generates frames at the target FPS
  • + *
  • The engine intelligently skips rendering when no visual changes are detected
  • + *
  • {@link FrameListener}s are notified before each potential frame, enabling animations
  • + *
  • Mouse/keyboard input is managed by {@link InputManager}
  • + *
  • Keyboard focus is managed by {@link KeyboardFocusStack}
  • + *
+ * + * @see ViewFrame convenience window wrapper + * @see ShapeCollection the scene graph + * @see Camera the camera/viewer + * @see FrameListener for per-frame callbacks + */ +public class ViewPanel extends Canvas { + private static final long serialVersionUID = 1683277888885045387L; + private static final int NUM_BUFFERS = 2; + + /** The input manager handling mouse and keyboard events. */ + private final InputManager inputManager = new InputManager(this); + /** The stack managing keyboard focus for GUI components. */ + private final KeyboardFocusStack keyboardFocusStack; + /** The camera representing the viewer's position and orientation. */ + private final Camera camera = new Camera(); + /** Head tracker hot-plug manager, unless disabled via e3d.headtrack=false. */ + private HeadTrackingManager headTrackingManager; + + /** SpaceNavigator hot-plug manager, unless disabled via e3d.spacemouse=false. */ + private SpaceMouseManager spaceMouseManager; + /** The root shape collection containing all 3D shapes in the scene. */ + private final ShapeCollection rootShapeCollection = new ShapeCollection(); + /** The set of frame listeners notified before each frame. */ + private final Set frameListeners = ConcurrentHashMap.newKeySet(); + /** + * Number of paint tiles per render thread. Tiles (not threads) are the + * unit of work: threads steal tiles off a shared ticket, so more tiles + * than threads lets fast threads pick up remaining tiles instead of + * idling behind a busy one. Measured 2026-09-04 (400-sphere scene, + * 1920x1080, HX 370): 10x oversampling beats 4x by ~15% and also + * beats pure horizontal bands; 16x adds a further win only on + * spatially clustered scenes. + */ + private static final int TILES_PER_THREAD = 10; + + /** The executor service for parallel rendering. */ + private ExecutorService renderExecutor = Executors.newFixedThreadPool(defaultRenderThreadCount()); + /** Number of render threads. Can be changed at runtime via {@link #setNumRenderThreads(int)}. */ + private volatile int numRenderThreads = defaultRenderThreadCount(); + /** + * Executor for the parallel transform phase, sized to all available cores. + * Created lazily on the render thread; daemon threads so it never blocks JVM exit. + */ + private ExecutorService transformExecutor = null; + /** The background color of the view. */ + public Color backgroundColor = Color.BLACK; + + /** Developer tools for this view panel. */ + private final DeveloperTools developerTools = new DeveloperTools(); + /** Debug log buffer for capturing diagnostic output. */ + private final DebugLogBuffer debugLogBuffer = new DebugLogBuffer(10000); + /** The developer tools panel popup, or null if not currently shown. */ + private DeveloperToolsPanel developerToolsPanel = null; + + /** + * Global lighting manager for the scene. + * Contains all light sources and ambient light settings. Shaded polygons + * access this via the RenderingContext during paint(). Add lights here + * to illuminate the world. + */ + private final LightingManager lightingManager = new LightingManager(); + + /** Progressive GI system, created by {@link #enableGlobalIllumination()}. */ + private eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination globalIllumination; + + /** + * Stores milliseconds when the last frame was updated. This is needed to calculate the time delta between frames. + * Time delta is used to calculate smooth animation. + */ + private long lastUpdateMillis = 0; + + /** The current rendering context for the active frame. */ + private RenderingContext renderingContext = null; + + /** + * Double-buffered frame contexts, indexed by frame parity. While frame N + * is still being painted from one buffer, frame N+1 already transforms + * into the other, so paint threads never idle waiting for the transform + * phase and transform threads never wait for the last tile. + */ + private final RenderingContext[] frameContexts = new RenderingContext[3]; + + /** Parity (0/1) of the frame currently being prepared. */ + private int frameParity = 0; + + /** + * Counts render passes (one per eye in stereo). A pass's projection + * buffer slot is {@code passCounter % 3}: with three slots, transform + * of pass P only conflicts with paint of pass P-3, so it can run + * while the two previous passes' paints are still in flight. + */ + private long passCounter = 0; + + /** + * Paint passes that were submitted to the shared executor but not yet + * awaited, oldest first. Depth stays at most 2: the pass being + * transformed now overlaps the previously submitted one, and freed + * workers flow from the tail of the older pass's tiles straight into + * the newer pass's tiles — consecutive passes' paints overlap on + * purpose (they write different parity framebuffers and read + * different parity vertex slots, so no ordering between them is + * required). + */ + private final java.util.ArrayDeque pendingPaints = new java.util.ArrayDeque<>(); + + /** + * Mailbox holding the newest completed frame awaiting presentation. + * Only the latest frame is kept: when the display path (X server at + * 4K, tens of MB per blit) is slower than production, stale frames + * are dropped instead of piling up latency — swapchain "mailbox + * mode". The render thread never blocks on the display: it deposits + * here and the dedicated present thread does the blitting. + */ + private final java.util.concurrent.atomic.AtomicReference mailboxFrame = + new java.util.concurrent.atomic.AtomicReference<>(); + + /** Signals the present thread that a frame was deposited. */ + private final java.util.concurrent.Semaphore presentSignal = new java.util.concurrent.Semaphore(0); + + /** Daemon thread performing all BufferStrategy blits. */ + private Thread presentThread; + + /** Run flag for the present thread. */ + private volatile boolean presentThreadRunning = false; + + /** + * Maximum blits per second the present thread issues IN CAPPED-FPS + * MODE. Exists because the X server also dispatches input: flooding + * it with blits causes desktop-wide mouse/keyboard jitter. Override + * with {@code -Daukio3d.presentRate=N} for high-refresh displays. + * + *

In benchmark mode (targetFPS <= 0) pacing is DISABLED: the + * pacing sleep delays the presented frame's gate release, and since + * paint(F+3) awaits that gate before reusing F's framebuffer, pacing + * the present thread used to cap PRODUCTION to roughly + * 2×PRESENT_RATE_LIMIT_FPS (measured: unlimited-FPS mode + * locked to ~120 FPS with 18 workers 70% idle, 2026-09-06).

+ */ + private static final int PRESENT_RATE_LIMIT_FPS = + Integer.parseInt(System.getProperty("aukio3d.presentRate", "60")); + + /** + * When false, each paint pass is awaited immediately after submission, + * restoring the old strictly-sequential phase order. Kill switch for + * benchmarking and regression hunting; also toggled at runtime from + * developer tools if needed. + */ + private volatile boolean pipelineEnabled = + !"false".equalsIgnoreCase(System.getProperty("aukio3d.pipeline", "true")); + + /** + * A paint pass whose tiles are still being worked on by the render + * executor. Awaited one pass later (software pipeline). + */ + /** A completed frame plus the gate to fire once it is presented or dropped. */ + private static final class PresentJob { + RenderingContext context; + java.util.concurrent.CountDownLatch gate; + } + + private static class PendingPaint { + volatile CountDownLatch latch; + RenderingContext context; + volatile SegmentRenderingContext[] segmentContexts; + int eyeOffsetX; + int eyeWidth; + /** True when this pass completes its frame (blit becomes due). */ + boolean lastPassOfFrame; + + /** Global pass index at submission time (slot = index mod 3). */ + long passIndex; + + /** The frame's own present gate, fired when it is presented or dropped. */ + java.util.concurrent.CountDownLatch frameGate; + + /** + * Counted down by the pass's asynchronous continuation once the + * paint tasks have been submitted and {@link #latch} / + * {@link #segmentContexts} are published. + */ + final java.util.concurrent.CountDownLatch ready = new java.util.concurrent.CountDownLatch(1); + } + + /** + * Currently target frames per second rate for this view. Target FPS can be changed at runtime. + * 3D engine tries to be smart and only repaints screen when there are visible changes. + * A value of 0 or less means unlimited FPS (benchmark mode). + */ + private volatile int targetFPS = 60; + + /** Frames blitted in the current FPS measurement window (render thread only). */ + private int fpsWindowFrames = 0; + + /** Start of the current FPS measurement window, nanoseconds (render thread only). */ + private long fpsWindowStartNanos = 0; + + /** Most recently measured display rate (frames blitted per second). */ + private volatile double measuredFPS = 0; + + /** + * Set to true if it is known than next frame needs to be painted. Flag is cleared + * immediately after frame got updated. + */ + private boolean viewRepaintNeeded = true; + + /** + * Render thread that runs the continuous frame generation loop. + */ + private Thread renderThread; + + /** + * Flag to control whether the render thread should keep running. + */ + private volatile boolean renderThreadRunning = false; + + /** Timestamp for the next scheduled frame. */ + private long nextFrameTime; + + /** + * The most recently completed frame, retained so a bug report can + * include a screenshot of what the user was seeing. Set by + * {@link #presentFrame}; the buffer is reused by the pipeline, so + * consumers must copy it. + */ + private volatile java.awt.image.BufferedImage lastFrameImage; + + /** The buffer strategy for page-flipping rendering (also read by the present thread). */ + private BufferStrategy bufferStrategy; + + /** Whether the buffer strategy has been initialized. */ + private boolean bufferStrategyInitialized = false; + + /** + * Creates a new view panel with default settings. + */ + public ViewPanel() { + frameListeners.add(camera); + frameListeners.add(inputManager); + + // persistent log + telemetry (idempotent); the view telemetry + // source reports frame production stats + Diagnostics.install(); + Telemetry.registerSource("view", () -> String.format( + "fps=%.1f targetFps=%d size=%dx%d renderThreads=%d", + getMeasuredFPS(), getTargetFPS(), getWidth(), getHeight(), + getNumRenderThreads())); + + keyboardFocusStack = new KeyboardFocusStack(this); + + initializeCanvas(); + initializeHeadTracking(); + initializeSpaceMouse(); + + // Set default ambient light for the scene + lightingManager.setAmbientLight(new Color(50, 50, 50)); + addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(final ComponentEvent e) { + viewRepaintNeeded = true; + startRenderThreadIfReady(); + } + + @Override + public void componentShown(final ComponentEvent e) { + viewRepaintNeeded = true; + startRenderThreadIfReady(); + } + }); + } + + private void startRenderThreadIfReady() { + if (isShowing() && getWidth() > 0 && getHeight() > 0) + startRenderThread(); + } + + /** + * Returns the camera representing the viewer's position and orientation. + * + * @return the camera + */ + public Camera getCamera() { + return camera; + } + + /** + * Returns the keyboard focus stack, which manages which component receives + * keyboard input. + * + * @return the keyboard focus stack + */ + public KeyboardFocusStack getKeyboardFocusStack() { + return keyboardFocusStack; + } + + /** + * Returns the root shape collection (scene graph). Add your 3D shapes here + * to make them visible in the view. + * + *
{@code
+     * viewPanel.getRootShapeCollection().addShape(myShape);
+     * }
+ * + * @return the root shape collection + */ + public ShapeCollection getRootShapeCollection() { + return rootShapeCollection; + } + + /** + * Returns the human input device (mouse/keyboard) event tracker. + * + * @return the HID event tracker + */ + /** + * Returns the input manager handling mouse and keyboard events for this view. + * + * @return the input manager + */ + public InputManager getInputManager() { + return inputManager; + } + + /** + * Registers a listener that will be notified before each frame render. + * Listeners can trigger repaints by returning {@code true} from + * {@link FrameListener#onFrame}. + * + * @param listener the listener to add + * @see #removeFrameListener(FrameListener) + */ + public void addFrameListener(final FrameListener listener) { + frameListeners.add(listener); + } + + @Override + public Dimension getPreferredSize() { + return new Dimension(640, 480); + } + + @Override + public Dimension getMinimumSize() { + return getPreferredSize(); + } + + @Override + public Dimension getMaximumSize() { + return getPreferredSize(); + } + + /** + * Returns the current rendering context for the active frame. + * + * @return the rendering context, or null if no frame is being rendered + */ + public RenderingContext getRenderingContext() { + return renderingContext; + } + + /** + * Returns the developer tools for this view panel. + * + * @return the developer tools + */ + public DeveloperTools getDeveloperTools() { + return developerTools; + } + + /** + * Returns the debug log buffer for this view panel. + * + * @return the debug log buffer + */ + public DebugLogBuffer getDebugLogBuffer() { + return debugLogBuffer; + } + + /** + * Returns the most recently completed frame, for bug report + * screenshots. The buffer belongs to the render pipeline and is + * reused — copy it before use. Null until the first frame completes. + * + * @return the last presented frame image, or null + */ + public java.awt.image.BufferedImage getLastFrameImage() { + return lastFrameImage; + } + + /** + * Returns the global lighting manager for the scene. + * Add light sources here to illuminate the world. + * + * @return the lighting manager + */ + public LightingManager getLightingManager() { + return lightingManager; + } + + /** + * Enables progressive global illumination with the default of 2 + * dedicated low-priority CPU threads. GI is opt-in: without this call + * lighting behaves exactly as before. Shadows and bounced light fade in + * over the first seconds and keep adapting to scene changes. + * + * @return the running GI system + */ + public eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination enableGlobalIllumination() { + return enableGlobalIllumination(2); + } + + /** + * Enables progressive global illumination with the given number of + * dedicated low-priority CPU threads. Calling again returns the + * already-running system. + * + * @param threadCount worker threads for ray tracing + * @return the running GI system + */ + public synchronized eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination enableGlobalIllumination( + final int threadCount) { + if (globalIllumination == null) { + globalIllumination = new eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination( + rootShapeCollection, lightingManager, threadCount); + globalIllumination.start(); + } + return globalIllumination; + } + + /** + * Shows the developer tools panel, toggling it if already open. + * Called when F12 is pressed. + */ + public void showDeveloperToolsPanel() { + if (developerToolsPanel != null && developerToolsPanel.isVisible()) { + developerToolsPanel.dispose(); + developerToolsPanel = null; + return; + } + + Frame parentFrame = null; + Container parent = getParent(); + while (parent != null) { + if (parent instanceof Frame) { + parentFrame = (Frame) parent; + break; + } + parent = parent.getParent(); + } + + developerToolsPanel = new DeveloperToolsPanel(parentFrame, this, developerTools, debugLogBuffer); + developerToolsPanel.setVisible(true); + } + + @Override + public void paint(final Graphics g) { + } + + @Override + public void update(final Graphics g) { + } + + private void initializeCanvas() { + setBackground(java.awt.Color.BLACK); + setFocusable(true); + // The canvas is the only focusable component in the frame; AWT + // focus traversal is meaningless here. Without this, AWT consumes + // Tab/Shift+Tab (and Ctrl+Tab) as traversal keys BEFORE they reach + // KeyListeners, so widgets (terminal, text editor Tab-indent) + // never see them. + setFocusTraversalKeysEnabled(false); + setIgnoreRepaint(true); + setVisible(true); + } + + @Override + public void addNotify() { + super.addNotify(); + requestFocus(); + } + + private void ensureBufferStrategy() { + if (bufferStrategyInitialized && bufferStrategy != null) + return; + + if (!isDisplayable() || getWidth() <= 0 || getHeight() <= 0) + return; + + try { + createBufferStrategy(NUM_BUFFERS); + bufferStrategy = getBufferStrategy(); + if (bufferStrategy != null) { + bufferStrategyInitialized = true; + // Prime the buffer strategy with an initial show() to ensure it's ready + Graphics2D g = null; + try { + g = (Graphics2D) bufferStrategy.getDrawGraphics(); + if (g != null) { + g.setColor(java.awt.Color.BLACK); + g.fillRect(0, 0, getWidth(), getHeight()); + } + } finally { + if (g != null) g.dispose(); + } + bufferStrategy.show(); + java.awt.Toolkit.getDefaultToolkit().sync(); + } + } catch (final Exception e) { + bufferStrategy = null; + bufferStrategyInitialized = false; + } + } + + private static int renderFrameCount = 0; + + private void renderFrame() { + ensureBufferStrategy(); + ensureExecutorMatchesThreadCount(); + + if (bufferStrategy == null || renderingContext == null) { + debugLogBuffer.log("[VIEWPANEL] renderFrame ABORT: bufferStrategy=" + bufferStrategy + ", renderingContext=" + renderingContext); + return; + } + + renderFrameCount++; + ThreadActivityRecorder.setFrameParity(frameParity); + + // Install this frame-use's present gate and capture the previous + // one: this frame's paint continuation will await the previous + // gate before writing pixels, so painting never overwrites a + // buffer the present thread is still blitting from. The frame's + // OWN gate travels with the PendingPaint to the deposit — the + // context field is reset again by the next frame reusing this + // context, long before this frame's flush reads it. + final java.util.concurrent.CountDownLatch previousGate = renderingContext.presentGate; + final java.util.concurrent.CountDownLatch frameGate = new java.util.concurrent.CountDownLatch(1); + renderingContext.presentGate = frameGate; + + try { + // Triple-buffered software pipeline, one render pass per eye. + // Vertex state, aggregators and framebuffers cycle through 3 + // slots, so transform(pass P) only needs paint(P-3) to be + // complete — it can run while the two previous passes' paints + // are still on the executor. Before each transform, completed + // paints are flushed (mouse hits, frame blit) and the render + // thread blocks ONLY if the pass P-3 paint is still running, + // which steady-state worker throughput prevents. Workers flow + // from one pass's tiles straight into the next pass's tiles + // with no gap: the next paint is always already queued. + if (stereoModeEnabled) { + final int eyeWidth = renderingContext.width / 2; + flushCompletedPasses(passCounter - 3); + final RenderingContext leftPass = transformPass(StereoEye.LEFT, eyeWidth, 0); + submitPaintPass(StereoEye.LEFT, eyeWidth, 0, false, leftPass, previousGate, frameGate); + flushCompletedPasses(passCounter - 3); + final RenderingContext rightPass = transformPass(StereoEye.RIGHT, eyeWidth, eyeWidth); + submitPaintPass(StereoEye.RIGHT, eyeWidth, eyeWidth, true, rightPass, previousGate, frameGate); + } else { + flushCompletedPasses(passCounter - 3); + final RenderingContext pass = transformPass(StereoEye.NONE, renderingContext.width, 0); + submitPaintPass(StereoEye.NONE, renderingContext.width, 0, true, pass, previousGate, frameGate); + } + frameParity = (frameParity + 1) % 3; + } catch (final Exception e) { + debugLogBuffer.log("[VIEWPANEL] renderFrame exception: " + e.getMessage()); + e.printStackTrace(); + bufferStrategyInitialized = false; + bufferStrategy = null; + } + } + + /** + * Blits a finished frame buffer to the screen via the buffer strategy. + * The re-blit loop handles OS back-buffer recreation: contentsRestored() + * triggers when the OS recreates the back buffer (common during window + * creation); since the offscreen bufferedImage still contains the + * correct frame data, only a re-blit is needed, never a re-render. + * + * @param context the frame context whose bufferedImage is complete + */ + /** + * Deposits a completed frame into the presentation mailbox and wakes + * the present thread. If the previous deposited frame has not been + * shown yet, it is dropped: displaying a stale frame when a newer one + * exists only adds latency. + * + * @param context the frame context whose buffer is complete + */ + private void presentFrame(final RenderingContext context, + final java.util.concurrent.CountDownLatch frameGate) { + noteFrameBlitted(); // counts PRODUCED frames (benchmark rate) + lastFrameImage = context.bufferedImage; + final PresentJob job = new PresentJob(); + job.context = context; + job.gate = frameGate; + final PresentJob dropped = mailboxFrame.getAndSet(job); + if (dropped != null) { + // Never shown: release its framebuffer for reuse immediately + dropped.gate.countDown(); + } + presentSignal.release(); + } + + /** + * Present thread loop: takes the newest mailbox frame and blits it. + * All slow display-path work (33 MB drawImage at 4K, BufferStrategy + * show, Toolkit.sync round-trip to the X server) happens here, never + * on the render thread that feeds the worker pool. + * + *

Paced to the display rate IN CAPPED-FPS MODE: without a cap, an + * unlimited-FPS pipeline floods the X server with hundreds of 33 MB + * blits per second, and since the X server also processes mouse and + * keyboard, the whole desktop gets input jitter. In benchmark mode + * (targetFPS <= 0) pacing is skipped: the sleep would delay the + * presented frame's gate release, and gate backpressure + * (paint(F+3) awaits blit/drop of F) would cap production to the + * present rate — the opposite of what benchmark mode is for. Frames + * the present thread cannot keep up with are dropped from the + * mailbox at deposit time and their gates fire immediately.

+ */ + private void presentLoop() { + final long presentIntervalNanos = 1_000_000_000L / PRESENT_RATE_LIMIT_FPS; + long nextPresent = System.nanoTime(); + while (presentThreadRunning) { + try { + presentSignal.acquire(); + } catch (final InterruptedException e) { + break; + } + final PresentJob job = mailboxFrame.getAndSet(null); + if (job != null) { + // Only pace when the user asked for a capped frame rate. + if (targetFPS > 0) { + nextPresent += presentIntervalNanos; + final long sleep = nextPresent - System.nanoTime(); + if (sleep > 0) { + try { + Thread.sleep(sleep / 1_000_000L, (int) (sleep % 1_000_000L)); + } catch (final InterruptedException e) { + break; + } + } else if (sleep < -presentIntervalNanos) { + nextPresent = System.nanoTime(); // fell behind: resync + } + } + try { + blitFrame(job.context, job.gate); + } catch (final Throwable t) { + // A blit failure must NEVER kill this thread: every + // frame's present gate depends on it. Drop the buffer + // strategy; the render thread recreates it next frame. + debugLogBuffer.log("[VIEWPANEL] present failed: " + t); + bufferStrategyInitialized = false; + bufferStrategy = null; + } finally { + job.gate.countDown(); + } + } + } + } + + private void blitFrame(final RenderingContext context, + final java.util.concurrent.CountDownLatch gate) { + if (bufferStrategy == null) + return; + final boolean trace = ThreadActivityRecorder.isEnabled(); + final long t0 = trace ? System.nanoTime() : 0; + try { + do { + Graphics2D g = null; + try { + g = (Graphics2D) bufferStrategy.getDrawGraphics(); + if (g != null) { + // Use image observer to ensure proper image loading + g.drawImage(context.bufferedImage, 0, 0, this); + } + } catch (final Exception e) { + debugLogBuffer.log("[VIEWPANEL] Blit exception: " + e.getMessage()); + break; + } finally { + if (g != null) g.dispose(); + } + } while (bufferStrategy.contentsRestored()); + + // Release the framebuffer for reuse NOW: the draw loop above is + // the only part that reads context.bufferedImage. show() and + // Toolkit.sync() below touch only the BufferStrategy's own back + // buffer and the X connection — at 4920x2960 they cost ~16 ms + // (vs ~8 ms for the draw), and holding the frame's gate across + // them used to stall the paint pass waiting to reuse this + // buffer (measured 2026-09-06: gate hold ~24 ms, production + // capped at ~52 FPS). The present thread's finally-block still + // fires the gate as a backstop (countDown is idempotent). + gate.countDown(); + + if (bufferStrategy.contentsLost()) { + debugLogBuffer.log("[VIEWPANEL] Buffer contents LOST, reinitializing"); + bufferStrategyInitialized = false; + bufferStrategy = null; + } else { + bufferStrategy.show(); + java.awt.Toolkit.getDefaultToolkit().sync(); + } + } finally { + if (trace) { + ThreadActivityRecorder.record(ThreadActivityRecorder.KIND_BLIT, t0, System.nanoTime()); + } + } + } + + /** + * Awaits the oldest pending paint pass (if any), processes its mouse + * hits, and schedules its frame buffer for blitting when it was the + * frame's last pass. Newer paint passes keep running meanwhile — the + * await only enforces the constraints that actually exist: vertex + * slot reuse (transform P+2 needs paint P done) and blit ordering. + */ + private void flushPendingPaint() { + final PendingPaint pending = pendingPaints.poll(); + if (pending == null) + return; + + try { + final boolean trace = ThreadActivityRecorder.isEnabled(); + final long t0 = trace ? System.nanoTime() : 0; + pending.ready.await(); + final CountDownLatch paintLatch = pending.latch; + if (paintLatch != null) { + paintLatch.await(); + } + if (trace) { + ThreadActivityRecorder.record(ThreadActivityRecorder.KIND_AWAIT, t0, System.nanoTime()); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + // Hi-Z: fold this frame's depth into the occlusion pyramid the + // next frame's block culling runs against. Only on successful + // paint (segmentContexts null = the pass failed — keep the + // previous pyramid rather than folding in partial depth). + if (pending.segmentContexts != null + && pending.context.occlusionPyramid != null) + pending.context.occlusionPyramid.buildFrom( + pending.context.depth, + pending.context.width, pending.context.height); + + // Only process mouse hits for the eye whose viewport contains the + // cursor. In stereo mode each eye sees a different camera position, + // so the same object may appear at different screen X in each eye. + // The cursor is in absolute screen coordinates and can only be in + // one eye's viewport; combining for the wrong eye would overwrite + // the correct hit with null. + final MouseEvent mouseEvent = pending.context.getMouseEvent(); + final boolean mouseInThisEye = (mouseEvent == null) + || (mouseEvent.coordinate.x >= pending.eyeOffsetX + && mouseEvent.coordinate.x < pending.eyeOffsetX + pending.eyeWidth); + // segmentContexts is null when the pass's paint continuation + // failed (e.g. a transform chunk threw): the frame is skipped, + // but that must not kill the render loop with an NPE here. + if (mouseInThisEye && pending.segmentContexts != null) { + combineMouseResults(pending.segmentContexts, pending.context); + viewRepaintNeeded = pending.context.handlePossibleComponentMouseEvent(); + } + + if (developerTools.showSegmentBoundaries) { + final RenderingContext context = pending.context; + final int tilesX = context.tilesX; + final int tilesY = context.tilesY; + final int tileH = context.height / tilesY; + final int tileW = pending.eyeWidth / tilesX; + final int[] pixels = context.pixels; + final int width = context.width; + final int height = context.height; + final int red = (255 << 16); + for (int ty = 1; ty < tilesY; ty++) { + final int offset = ty * tileH * width; + Arrays.fill(pixels, offset + pending.eyeOffsetX, + offset + pending.eyeOffsetX + pending.eyeWidth, red); + } + for (int tx = 1; tx < tilesX; tx++) { + final int x = pending.eyeOffsetX + tx * tileW; + for (int y = 0; y < height; y++) + pixels[y * width + x] = red; + } + } + + if (pending.lastPassOfFrame) { + // Hand the completed frame to the present thread (mailbox: + // only the newest is shown). The render thread never blocks + // on the display; workers stay fed from the queue meanwhile. + presentFrame(pending.context, pending.frameGate); + } + } + + /** + * Flushes every pending paint that is already complete, plus — when + * correctness demands — awaits older passes. Transform of pass P may + * only start once paint(P-3) is complete (vertex slot and aggregator + * cycle of 3); callers pass P-3 as {@code maxPassIndex}. Passes newer + * than that are flushed without blocking when their latch already + * reached zero, so completed frames are blitted as early as possible. + * + * @param maxPassIndex passes up to this index MUST be complete on return + */ + private void flushCompletedPasses(final long maxPassIndex) { + while (true) { + final PendingPaint head = pendingPaints.peek(); + if (head == null) + return; + if (head.passIndex > maxPassIndex + && (head.ready.getCount() > 0 + || head.latch == null + || head.latch.getCount() > 0)) + return; // still being prepared or painted; leave it queued + flushPendingPaint(); + } + } + + /** + * Clears a single tile's pixel area to the background color. + * Called by each render thread before painting shapes. + * The tile context carries exact X and Y bounds (X bounds also cover + * the stereo per-eye viewport). + * + * @param ctx the tile rendering context with X/Y bounds + */ + private void clearSegmentPixels(final SegmentRenderingContext ctx) { + final int rgb = (backgroundColor.r << 16) | (backgroundColor.g << 8) | backgroundColor.b; + final int width = ctx.width; + final int[] pixels = ctx.pixels; + + final int minX = ctx.renderMinX; + final int maxX = ctx.renderMaxX; + + final float[] depth = ctx.depth; + for (int y = ctx.renderMinY; y < ctx.renderMaxY; y++) { + final int rowOffset = y * width; + Arrays.fill(pixels, rowOffset + minX, rowOffset + maxX, rgb); + Arrays.fill(depth, rowOffset + minX, rowOffset + maxX, + Float.NEGATIVE_INFINITY); + } + } + + private void combineMouseResults(final SegmentRenderingContext[] segmentContexts, + final RenderingContext context) { + // All segments paint shapes back-to-front, and mouse hit detection + // happens before Y-bound clipping. So each segment should report the + // same "last hit" (frontmost shape under mouse). Just take the first non-null. + for (final SegmentRenderingContext ctx : segmentContexts) { + final MouseInteractionController hit = ctx.getSegmentMouseHit(); + if (hit != null) { + context.setCurrentObjectUnderMouseCursor(hit, + ctx.getSegmentMouseHitU(), ctx.getSegmentMouseHitV()); + return; + } + } + } + + /** + * Calling these methods tells 3D engine that current 3D view needs to be + * repainted on first opportunity. + */ + public void repaintDuringNextViewUpdate() { + viewRepaintNeeded = true; + } + + /** + * Set target frames per second rate for this view. Target FPS can be changed at runtime. + * Use 0 or negative value for unlimited FPS (max performance mode for benchmarking). + * + * @param frameRate target frames per second rate for this view. + */ + public void setFrameRate(final int frameRate) { + targetFPS = frameRate; + } + + /** + * Returns the current target frames per second rate. + * + * @return target FPS; 0 or less means unlimited + */ + public int getTargetFPS() { + return targetFPS; + } + + /** + * Returns the measured production rate: frames completed per second, + * averaged over the last ~500 ms window. This is the benchmark number: + * how fast the pipeline produces frames, regardless of how quickly + * the display path presents them (the mailbox present thread may + * drop stale frames when the display is slower than production). + * + * @return measured frames per second + */ + public double getMeasuredFPS() { + return measuredFPS; + } + + /** + * Counts one blitted frame into the FPS measurement window. + * Called by the render thread after each completed blit. + */ + private void noteFrameBlitted() { + fpsWindowFrames++; + final long now = System.nanoTime(); + if (fpsWindowStartNanos == 0) { + fpsWindowStartNanos = now; + fpsWindowFrames = 0; + return; + } + final long elapsed = now - fpsWindowStartNanos; + if (elapsed >= 500_000_000L) { + measuredFPS = fpsWindowFrames * 1e9 / elapsed; + fpsWindowStartNanos = now; + fpsWindowFrames = 0; + } + } + + /** + * Returns the current number of render threads. + * + * @return the number of render threads + */ + public int getNumRenderThreads() { + return numRenderThreads; + } + + /** + * Sets the number of render threads. Takes effect on the next frame. + * The executor service is recreated lazily when the render loop detects the change. + * + * @param count number of render threads (must be at least 1) + */ + public void setNumRenderThreads(final int count) { + if (count < 1) + throw new IllegalArgumentException("Render thread count must be at least 1, got: " + count); + numRenderThreads = count; + viewRepaintNeeded = true; + } + + // ------------------------------------------------------------------ + // Stereo rendering + // ------------------------------------------------------------------ + + /** + * Default inter-pupillary distance in world units (centimeters). + * Human IPD ranges from ~5.5 to ~7.5 cm; 6.5 cm is the population median. + */ + private static final double DEFAULT_STEREO_IPD = 6.5; + + /** Inter-pupillary distance in world units, configurable at runtime. */ + private double stereoIPD = EngineConfig.getIpdCm(); + + /** Whether side-by-side stereoscopic rendering is enabled. */ + private boolean stereoModeEnabled = false; + + /** + * Returns whether side-by-side stereoscopic rendering is currently enabled. + * + * @return {@code true} if stereo mode is active + */ + public boolean isStereoModeEnabled() { + return stereoModeEnabled; + } + + /** + * Enables or disables side-by-side stereoscopic rendering. + * When enabled, each frame renders two eye views side-by-side. + * + * @param enabled {@code true} to enable stereo mode, {@code false} to disable + */ + public void setStereoModeEnabled(final boolean enabled) { + this.stereoModeEnabled = enabled; + viewRepaintNeeded = true; + } + + /** + * Returns the current inter-pupillary distance used for stereo rendering. + * + * @return IPD in world units (centimeters) + */ + public double getStereoIPD() { + return stereoIPD; + } + + /** + * Sets the inter-pupillary distance for stereo rendering. + * Human IPD ranges from ~5.5 to ~7.5 cm; for XR glasses the optical + * IPD may differ from the user's anatomical IPD. + * + * @param ipd the inter-pupillary distance in world units (centimeters) + */ + public void setStereoIPD(final double ipd) { + this.stereoIPD = ipd; + viewRepaintNeeded = true; + } + + /** + * Runs the transform phase of a single eye pass: offsets the camera for + * the eye, updates the frame context viewport fields, transforms, sorts + * and tile-bins the scene. The pass's projection slot is + * {@code passCounter & 1}; its paint (submitted later by + * {@link #submitPaintPass}) reads the same slot from copies taken while + * it is still current. + * + * @param eye which eye to render + * @param eyeWidth width of the eye viewport in pixels + * @param eyeOffsetX X offset of the eye viewport within the full buffer + */ + private RenderingContext transformPass(final StereoEye eye, final int eyeWidth, final int eyeOffsetX) { + final boolean trace = ThreadActivityRecorder.isEnabled(); + final long t0 = trace ? System.nanoTime() : 0; + final Camera camera = getCamera(); + final Point3D location = camera.getTransform().getTranslation(); + + final double originalX = location.x; + if (eye != StereoEye.NONE) { + final double ipdOffset = (eye == StereoEye.LEFT) ? -stereoIPD / 2.0 : stereoIPD / 2.0; + location.x += ipdOffset; + } + + try { + // Independent per-pass context: the walk, its forked chunk + // tasks and the asynchronous sort/bin/paint continuation all + // read this copy, so the NEXT pass's setup (a new copy) + // cannot disturb work that is still in flight. + final RenderingContext passContext = new RenderingContext(renderingContext); + passContext.stereoEye = eye; + passContext.stereoViewportWidth = eyeWidth; + passContext.stereoViewportOffsetX = eyeOffsetX; + passContext.renderMinX = eyeOffsetX; + passContext.renderMaxX = eyeOffsetX + eyeWidth; + passContext.centerCoordinate.x = eyeWidth / 2.0; + passContext.projectionScale = eyeWidth / 3.0; + passContext.vertexSlot = (int) (passCounter % 3); + + // Walks the tree and forks heavy composites into chunk tasks, + // but does NOT wait for them: draining happens inside the + // pass's continuation on a worker thread. + rootShapeCollection.transformShapesBegin(this, passContext); + return passContext; + } finally { + location.x = originalX; + if (trace) { + ThreadActivityRecorder.record(ThreadActivityRecorder.KIND_RENDER, t0, System.nanoTime()); + } + } + } + + /** + * Submits the paint phase of a single eye pass to the render executor + * and returns immediately (unless the pipeline kill switch is off). + * Tiles are work-stolen off a shared ticket; output is unaffected — + * every tile is still painted by exactly one thread, in fixed + * (Z, shapeId) order. + * + * @param eye which eye this pass renders + * @param eyeWidth width of the eye viewport in pixels + * @param eyeOffsetX X offset of the eye viewport within the buffer + * @param lastPassOfFrame true when this pass completes its frame + */ + private void submitPaintPass(final StereoEye eye, final int eyeWidth, final int eyeOffsetX, + final boolean lastPassOfFrame, final RenderingContext passContext, + final java.util.concurrent.CountDownLatch previousGate, + final java.util.concurrent.CountDownLatch frameGate) { + final RenderingContext frameContext = renderingContext; + final int tilesX = frameContext.tilesX; + final int tilesY = frameContext.tilesY; + final int height = frameContext.height; + final int slot = passContext.vertexSlot; + final ExecutorService executor = getOrCreateTransformExecutor(); + + // Enqueue the pending-paint shell synchronously so pendingPaints + // stays in pass order; the continuation fills in the rest. + final PendingPaint pending = new PendingPaint(); + pending.context = frameContext; + pending.eyeOffsetX = eyeOffsetX; + pending.eyeWidth = eyeWidth; + pending.lastPassOfFrame = lastPassOfFrame; + pending.passIndex = passCounter; + pending.frameGate = frameGate; + pendingPaints.addLast(pending); + + passCounter++; + + final int tracePaintKind = ThreadActivityRecorder.KIND_PAINT + ThreadActivityRecorder.frameParity(); + + // The whole rest of the pass is one asynchronous continuation on + // the shared executor: drain the transform chunks (a ForkJoinTask + // get() here work-steals instead of blocking), depth-sort on the + // same pool, bin per tile, then submit the paint ticket tasks. + // The render thread never waits for any of it. + executor.submit(() -> { + final boolean trace = ThreadActivityRecorder.isEnabled(); + final long t0 = trace ? System.nanoTime() : 0; + try { + long ts = trace ? System.nanoTime() : 0; + rootShapeCollection.drainTransformShapes(passContext); + if (trace) { + ThreadActivityRecorder.record(ThreadActivityRecorder.KIND_DRAIN, ts, System.nanoTime()); + ts = System.nanoTime(); + } + // Present gate: never overwrite a buffer the present + // thread is still blitting from (frame F-3's contents). + // Fires instantly unless the display is >=3 frames behind. + // The timeout is insurance, not control flow: a present + // path failure must degrade to a torn frame, never to a + // frozen pipeline. + if (!previousGate.await(2, java.util.concurrent.TimeUnit.SECONDS)) { + debugLogBuffer.log("[VIEWPANEL] present gate timeout — presenting may be stuck"); + } + if (trace) { + ThreadActivityRecorder.record(ThreadActivityRecorder.KIND_AWAIT, ts, System.nanoTime()); + } + ts = trace ? System.nanoTime() : 0; + rootShapeCollection.sortShapes(slot, executor); + if (trace) { + ThreadActivityRecorder.record(ThreadActivityRecorder.KIND_SORT, ts, System.nanoTime()); + } + rootShapeCollection.binShapesForTiles(slot, tilesX, tilesY, + eyeOffsetX, eyeWidth, height, executor); + + final int tileW = eyeWidth / tilesX; + final int tileH = height / tilesY; + final int segments = tilesX * tilesY; + // In stereo the right eye's segment indices follow the left eye's + final int eyeBase = (eye == StereoEye.RIGHT) ? segments : 0; + + final SegmentRenderingContext[] segmentContexts = new SegmentRenderingContext[segments]; + for (int ty = 0; ty < tilesY; ty++) { + final int minY = ty * tileH; + final int maxY = (ty == tilesY - 1) ? height : (ty + 1) * tileH; + for (int tx = 0; tx < tilesX; tx++) { + final int minX = eyeOffsetX + tx * tileW; + final int maxX = (tx == tilesX - 1) ? eyeOffsetX + eyeWidth : minX + tileW; + final int index = ty * tilesX + tx; + final SegmentRenderingContext tileContext = new SegmentRenderingContext( + passContext, minY, maxY, eyeBase + index); + tileContext.vertexSlot = slot; + tileContext.renderMinX = minX; + tileContext.renderMaxX = maxX; + segmentContexts[index] = tileContext; + } + } + + // Per-tile paint tasks on the fork/join pool: a worker + // finishing one tile immediately pulls ANY next queued + // work — another tile (of this or an adjacent frame's + // pass), a transform chunk, a continuation — so cores + // never idle waiting for a ticket-loop task to end. + final CountDownLatch paintLatch = new CountDownLatch(segments); + + for (int s = 0; s < segments; s++) { + final int segmentIndex = s; + if (developerTools.renderAlternateSegments && (segmentIndex % 2 == 1)) { + paintLatch.countDown(); + continue; + } + executor.submit(() -> { + final long pt0 = trace ? System.nanoTime() : 0; + try { + clearSegmentPixels(segmentContexts[segmentIndex]); + rootShapeCollection.paintShapes(segmentContexts[segmentIndex]); + } finally { + if (trace) { + ThreadActivityRecorder.record(tracePaintKind, pt0, System.nanoTime()); + } + paintLatch.countDown(); + } + }); + } + + pending.segmentContexts = segmentContexts; + pending.latch = paintLatch; + } catch (final Throwable t) { + debugLogBuffer.log("[VIEWPANEL] paint continuation failed: " + t); + t.printStackTrace(); + } finally { + if (trace) { + ThreadActivityRecorder.record(ThreadActivityRecorder.KIND_PREP, t0, System.nanoTime()); + } + pending.ready.countDown(); + } + }); + + if (!pipelineEnabled) { + flushCompletedPasses(Long.MAX_VALUE); + } + } + + // ------------------------------------------------------------------ + + /** + * Default number of paint segment threads: 75% of available CPU + * threads, clamped to at least 1 and at most (CPU threads - 1), so + * one thread always stays free for the rest of the system. + * + * @return the default render thread count + */ + private static int defaultRenderThreadCount() { + final int cores = Runtime.getRuntime().availableProcessors(); + return Math.max(1, Math.min((int) Math.round(cores * 0.75), cores - 1)); + } + + /** + * Returns the shared transform executor, creating it on first use. + * Called from the render thread only (no synchronization needed). + * + * @return the transform executor + */ + private ExecutorService getOrCreateTransformExecutor() { + if (transformExecutor == null || transformExecutor.isShutdown()) { + final int threads = numRenderThreads; + final AtomicInteger workerCounter = new AtomicInteger(); + // ForkJoinPool, not a fixed thread pool: the instrumented + // parallel merge sort and bin/merge copy tasks fork onto THIS + // pool (never the common pool), and a worker blocked in + // ForkJoinTask.get() (continuation draining transform chunks) + // work-steals other tasks instead of idling. asyncMode = FIFO + // submission queues, so older frames' work is preferred. + // Sized to the ALLOCATED thread count (75% of cores), not all + // cores: measured 2026-09-05 (UtilizationBench) that 24/24 + // threads run only ~70% busy — GC/JIT/OS threads displace + // workers and stretch frame tails — while 18/18 stay ~83%+ + // busy AND deliver higher FPS. + transformExecutor = new java.util.concurrent.ForkJoinPool(threads, + pool -> { + final java.util.concurrent.ForkJoinWorkerThread thread = + java.util.concurrent.ForkJoinPool.defaultForkJoinWorkerThreadFactory + .newThread(pool); + thread.setName("e3d-worker-" + workerCounter.getAndIncrement()); + thread.setDaemon(true); + return thread; + }, null, true); + } + return transformExecutor; + } + + /** + * Recreates the executor service if the thread count has changed since last creation. + * Called from the render thread only (no synchronization needed). + */ + private void ensureExecutorMatchesThreadCount() { + if (renderExecutor == null || renderExecutor.isShutdown()) { + renderExecutor = Executors.newFixedThreadPool(numRenderThreads); + return; + } + if (renderExecutor instanceof java.util.concurrent.ThreadPoolExecutor) { + final java.util.concurrent.ThreadPoolExecutor tpe = (java.util.concurrent.ThreadPoolExecutor) renderExecutor; + if (tpe.getCorePoolSize() != numRenderThreads) { + tpe.shutdown(); + renderExecutor = Executors.newFixedThreadPool(numRenderThreads); + } + } + } + + /** + * Starts the head tracking hot-plug manager: RayNeo glasses are + * detected when plugged in (even after startup) and head look-around + * is enabled automatically. Disable with {@code -De3d.headtrack=false}. + */ + private void initializeHeadTracking() { + if ("false".equalsIgnoreCase( + System.getProperty("e3d.headtrack", "true"))) + return; + headTrackingManager = new HeadTrackingManager(this); + headTrackingManager.start(); + } + + /** + * Starts the SpaceNavigator hot-plug manager: the 6DOF mouse is + * detected when plugged in (even after startup) and cap deflection + * drives the camera automatically. Disable with + * {@code -De3d.spacemouse=false}. + */ + private void initializeSpaceMouse() { + if ("false".equalsIgnoreCase( + System.getProperty("e3d.spacemouse", "true"))) + return; + spaceMouseManager = new SpaceMouseManager(this); + spaceMouseManager.start(); + } + + /** + * Returns the active SpaceNavigator device, or null when no 6DOF + * mouse is currently connected. + */ + public SpaceNavigatorHid getSpaceMouse() { + return spaceMouseManager == null ? null + : spaceMouseManager.getDevice(); + } + + /** + * Returns the active head tracker, or null when no glasses are + * currently connected. + */ + public HeadTracker getHeadTracker() { + return headTrackingManager == null ? null + : headTrackingManager.getTracker(); + } + + /** + * Stops rendering of this view. + */ + public void stop() { + if (headTrackingManager != null) { + headTrackingManager.stop(); + headTrackingManager = null; + } + if (spaceMouseManager != null) { + spaceMouseManager.stop(); + spaceMouseManager = null; + } + renderThreadRunning = false; + presentThreadRunning = false; + presentSignal.release(); + final PresentJob dropped = mailboxFrame.getAndSet(null); + if (dropped != null) { + dropped.gate.countDown(); + } + pendingPaints.clear(); + renderExecutor.shutdownNow(); + if (transformExecutor != null) { + transformExecutor.shutdownNow(); + } + if (renderThread != null) { + try { + renderThread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + renderThread = null; + } + } + + /** + * Starts the render thread that continuously generates frames. + */ + private synchronized void startRenderThread() { + if (renderThread != null) + return; + + renderThreadRunning = true; + renderThread = new Thread(this::renderLoop, "e3d-render"); + renderThread.setDaemon(true); + renderThread.start(); + + if (!presentThreadRunning) { + presentThreadRunning = true; + presentThread = new Thread(this::presentLoop, "e3d-present"); + presentThread.setDaemon(true); + presentThread.start(); + } + } + + /** + * Main render loop that generates frames continuously. + * Supports both unlimited FPS and fixed FPS modes with dynamic sleep adjustment. + */ + private void renderLoop() { + nextFrameTime = System.currentTimeMillis(); + + while (renderThreadRunning) { + try { + ensureThatViewIsUpToDate(); + } catch (final Exception e) { + e.printStackTrace(); + } + + if (maintainTargetFps()) break; + } + } + + /** + * Ensures that the rendering process maintains the target frames per second (FPS) + * by dynamically adjusting the thread sleep duration. + * + * @return {@code true} if the thread was interrupted while sleeping, otherwise {@code false}. + */ + private boolean maintainTargetFps() { + if (targetFPS <= 0) return false; + + long now = System.currentTimeMillis(); + + nextFrameTime += 1000L / targetFPS; + + // If we've fallen behind, reset to now instead of trying to catch up + if (nextFrameTime < now) + nextFrameTime = now; + + long sleepTime = nextFrameTime - now; + if (sleepTime > 0) { + try { + Thread.sleep(sleepTime); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return true; + } + } + return false; + } + + /** + * This method is executed by periodic timer task, in frequency according to + * defined frame rate. + *

+ * It tells view to update itself. View can decide if actual re-rendering of + * graphics is needed. + */ + void ensureThatViewIsUpToDate() { + maintainRenderingContext(); + + final int millisecondsPassedSinceLastUpdate = getMillisecondsPassedSinceLastUpdate(); + + boolean renderFrame = notifyFrameListeners(millisecondsPassedSinceLastUpdate); + + // Unlimited FPS is benchmark mode: render continuously even when + // the scene reports no changes, so the measured rate reflects + // maximum achievable throughput. + if (targetFPS <= 0) { + renderFrame = true; + } + + if (viewRepaintNeeded) { + viewRepaintNeeded = false; + renderFrame = true; + } + + // abort rendering if window size is invalid + if ((getWidth() > 0) && (getHeight() > 0) && renderFrame) { + renderFrame(); + } else { + // No new frame: make sure the last submitted paint passes + // still get awaited, mouse-processed and blitted. + flushCompletedPasses(Long.MAX_VALUE); + } + } + + private void maintainRenderingContext() { + int panelWidth = getWidth(); + int panelHeight = getHeight(); + + if (panelWidth <= 0 || panelHeight <= 0) { + if (frameContexts[0] != null) { + frameContexts[0].dispose(); + frameContexts[1].dispose(); + frameContexts[2].dispose(); + frameContexts[0] = null; + frameContexts[1] = null; + frameContexts[2] = null; + renderingContext = null; + pendingPaints.clear(); + } + return; + } + + // create new rendering contexts if window size has changed OR the + // tile grid has changed (thread count, stereo toggle) + final int viewportCount = stereoModeEnabled ? 2 : 1; + final int viewportWidth = panelWidth / viewportCount; + // Total tiles ~= 10x paint threads (work-stealing granularity); + // split into roughly square tiles: squares minimize boundary + // crossings, i.e. how many tiles each shape overlaps + final int targetTiles = Math.max(1, numRenderThreads * TILES_PER_THREAD); + final int tilesX = Math.max(1, Math.min(viewportWidth, (int) Math.round( + Math.sqrt(targetTiles * (double) viewportWidth / panelHeight)))); + final int tilesY = Math.max(1, Math.min(panelHeight, + (int) Math.round((double) targetTiles / tilesX))); + if ((frameContexts[0] == null) + || (frameContexts[0].width != panelWidth) + || (frameContexts[0].height != panelHeight) + || (frameContexts[0].tilesX != tilesX) + || (frameContexts[0].tilesY != tilesY) + || (frameContexts[0].viewportCount != viewportCount)) { + // The pending paints still work on the old buffers; let them + // finish before disposing the contexts they write into. + flushCompletedPasses(Long.MAX_VALUE); + final PresentJob droppedJob = mailboxFrame.getAndSet(null); + if (droppedJob != null) { + droppedJob.gate.countDown(); + } + for (int parity = 0; parity < 3; parity++) { + if (frameContexts[parity] != null) { + frameContexts[parity].dispose(); + } + final RenderingContext context = new RenderingContext(panelWidth, panelHeight, + tilesX, tilesY, viewportCount); + context.developerTools = developerTools; + context.debugLogBuffer = debugLogBuffer; + context.lightingManager = lightingManager; + frameContexts[parity] = context; + } + } + + renderingContext = frameContexts[frameParity]; + renderingContext.transformExecutor = getOrCreateTransformExecutor(); + renderingContext.prepareForNewFrameRendering(); + } + + private boolean notifyFrameListeners(int millisecondsPassedSinceLastUpdate) { + boolean reRenderFrame = false; + for (final FrameListener listener : frameListeners) + if (listener.onFrame(this, millisecondsPassedSinceLastUpdate)) + reRenderFrame = true; + return reRenderFrame; + } + + private int getMillisecondsPassedSinceLastUpdate() { + final long currentTime = System.currentTimeMillis(); + + if (lastUpdateMillis == 0) + lastUpdateMillis = currentTime; + + final int millisecondsPassedSinceLastUpdate = (int) (currentTime - lastUpdateMillis); + lastUpdateMillis = currentTime; + return millisecondsPassedSinceLastUpdate; + } + + /** + * Removes a previously registered frame listener. + * + * @param frameListener the listener to remove + */ + public void removeFrameListener(FrameListener frameListener) { + frameListeners.remove(frameListener); + } + +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewSpaceTracker.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewSpaceTracker.java new file mode 100644 index 0000000..40f602a --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewSpaceTracker.java @@ -0,0 +1,111 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.math.TransformStack; +import eu.svjatoslav.aukio.e3d.math.Vertex; + +/** + * Tracks an object's position in view/camera space for distance and angle calculations. + * + *

Used primarily for level-of-detail (LOD) decisions based on how far and at what + * angle the viewer is from an object. The tracker maintains the object's center point + * transformed into view space, and optionally orientation axes for angle calculations.

+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape + */ +public class ViewSpaceTracker { + + /** + * The object's center point (0,0,0 in object space) transformed to view space. + */ + public Vertex center = new Vertex(); + + /** + * Point at (10,0,0) in object space, used for XZ angle calculation. + * Only initialized if orientation tracking is enabled. + */ + public Vertex right; + + /** + * Point at (0,10,0) in object space, used for YZ angle calculation. + * Only initialized if orientation tracking is enabled. + */ + public Vertex down; + + /** + * Creates a new view space tracker. + */ + public ViewSpaceTracker() { + } + + /** + * Transforms the tracked points from object space to view space. + * + * @param transformPipe the current transform stack + * @param renderingContext the rendering context for frame info + */ + public void analyze(final TransformStack transformPipe, + final RenderingContext renderingContext) { + + center.calculateLocationRelativeToViewer(transformPipe, renderingContext); + + if (right != null) { + right.calculateLocationRelativeToViewer(transformPipe, renderingContext); + down.calculateLocationRelativeToViewer(transformPipe, renderingContext); + } + } + + /** + * Enables tracking of orientation axes for angle calculations. + * Disabled by default to save computation when angles are not needed. + */ + public void enableOrientationTracking() { + right = new Vertex(new Point3D(10, 0, 0)); + down = new Vertex(new Point3D(0, 10, 0)); + } + + /** + * Returns the angle between the viewer and object in the XY plane. + * + * @return the XY angle in radians + */ + public double getAngleXY(final RenderingContext renderingContext) { + return center.transformedCoordinate(renderingContext) + .getAngleXY(down.transformedCoordinate(renderingContext)); + } + + /** + * Returns the angle between the viewer and object in the XZ plane. + * + * @return the XZ angle in radians + */ + public double getAngleXZ(final RenderingContext renderingContext) { + return center.transformedCoordinate(renderingContext) + .getAngleXZ(right.transformedCoordinate(renderingContext)); + } + + /** + * Returns the angle between the viewer and object in the YZ plane. + * + * @return the YZ angle in radians + */ + public double getAngleYZ(final RenderingContext renderingContext) { + return center.transformedCoordinate(renderingContext) + .getAngleYZ(down.transformedCoordinate(renderingContext)); + } + + /** + * Returns the distance from the camera to the object's center. + * Used for level-of-detail calculations. + * + * @return the distance in world units + */ + public double getDistanceToCamera(final RenderingContext renderingContext) { + return center.transformedCoordinate(renderingContext).getVectorLength(); + } + +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewUpdateTimerTask.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewUpdateTimerTask.java new file mode 100755 index 0000000..061880c --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewUpdateTimerTask.java @@ -0,0 +1,31 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui; + +/** + * Timer task that updates view. + * + * Tries to keep constant FPS. + */ +public class ViewUpdateTimerTask extends java.util.TimerTask { + + /** The view panel to update. */ + public ViewPanel viewPanel; + + /** + * Creates a new timer task for the given view panel. + * + * @param viewPanel the view panel to update + */ + public ViewUpdateTimerTask(final ViewPanel viewPanel) { + this.viewPanel = viewPanel; + } + + @Override + public void run() { + viewPanel.ensureThatViewIsUpToDate(); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadLookController.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadLookController.java new file mode 100644 index 0000000..dab2fc8 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadLookController.java @@ -0,0 +1,144 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.headtrack; + +import eu.svjatoslav.aukio.e3d.gui.FrameListener; +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; +import eu.svjatoslav.aukio.e3d.math.Quaternion; + +import java.awt.event.KeyEvent; + +/** + * Applies head orientation from XR glasses to the camera, every frame: + * turn your head left and the view pans left, look up and the view + * tilts up. The virtual world stays fixed in space while the display + * moves with your head. + * + *

Complementary mouse look: mouse drag and head tracking + * compose instead of fighting. Every frame the controller compares the + * camera rotation against what it wrote last frame; a mismatch means + * the mouse moved the view, and the difference is folded into the base + * yaw/pitch so the view stays exactly where the mouse put it — + * subsequent head motion then composes on top. Head movement during a + * drag is not lost: it re-applies as a relative delta once the drag + * ends.

+ * + *

Press Scroll Lock to recenter: the current head pose becomes + * "straight ahead" and the camera returns to its base pose. Needed + * occasionally because gyro-only yaw drifts slowly.

+ * + *

Arrow-key movement and wheel vertical movement are unaffected + * (they translate, not rotate).

+ * + *

Head roll is measured but deliberately not applied — a constant + * world horizon reads better than a world that tilts with your neck.

+ */ +public final class HeadLookController implements FrameListener { + + /** + * Sign mapping, confirmed by live user testing: positive engine yaw + * turns the view left, so head-yaw is added directly; head-down + * integrates to positive pitch while negative engine pitch looks + * down, so pitch is subtracted. + */ + private static final double YAW_SIGN = 1.0; + private static final double PITCH_SIGN = -1.0; + + /** Yaw sensitivity multiplier; 2.0 = the view turns twice as far as + * the head (live-tuned 2026-09-10: head yaw was under-responsive). */ + private static final double YAW_GAIN = 2.0; + + /** Pitch sensitivity multiplier; 1.0 = the world stays fixed in space. */ + private static final double PITCH_GAIN = 1.0; + + /** + * Two rotations are "the same" below this per-component distance. + * A rotation this controller wrote itself compares bitwise identical; + * anything past this epsilon came from another writer (mouse drag). + */ + private static final double SAME_ROTATION_EPSILON = 1e-9; + + private final HeadTracker tracker; + private final ViewPanel viewPanel; + + private double baseYaw, basePitch; + + /** Rotation this controller wrote to the camera last frame. */ + private Quaternion lastApplied; + + private boolean recenteredOnce; + private boolean scrollLockWasPressed; + + public HeadLookController(final HeadTracker tracker, + final ViewPanel viewPanel) { + this.tracker = tracker; + this.viewPanel = viewPanel; + } + + @Override + public boolean onFrame(final ViewPanel viewPanel, + final int millisecondsSinceLastFrame) { + if (!tracker.isCalibrated()) + return false; + + final boolean scrollLockPressed = viewPanel.getInputManager() + .isKeyPressed(KeyEvent.VK_SCROLL_LOCK); + if (scrollLockPressed && !scrollLockWasPressed) { + captureBasePose(); + tracker.recenter(); + recenteredOnce = true; + lastApplied = null; + } + scrollLockWasPressed = scrollLockPressed; + + if (!recenteredOnce) { + // Boot recenter: capture the camera pose the application + // configured (not the constructor-time identity) and make + // the current head pose "straight ahead". + captureBasePose(); + tracker.recenter(); + recenteredOnce = true; + } + + final double lookYaw = tracker.getLookYaw(); + final double lookPitch = tracker.getLookPitch(); + + if (lastApplied != null) { + final Quaternion current = viewPanel.getCamera() + .getTransform().getRotation(); + if (!nearlyEqual(current, lastApplied)) { + // An external writer (mouse drag) moved the camera. + // Re-derive base yaw/pitch so the view stays exactly + // where the mouse left it — no snap-back. + final double[] angles = current.toAngles(); + baseYaw = angles[0] - YAW_SIGN * YAW_GAIN * lookYaw; + basePitch = angles[1] - PITCH_SIGN * PITCH_GAIN * lookPitch; + } + } + + final double yaw = baseYaw + YAW_SIGN * YAW_GAIN * lookYaw; + final double pitch = basePitch + PITCH_SIGN * PITCH_GAIN * lookPitch; + final Quaternion applied = Quaternion.fromAngles(yaw, pitch, 0); + viewPanel.getCamera().getTransform().getRotation().set(applied); + lastApplied = applied; + + return true; + } + + private void captureBasePose() { + final double[] angles = viewPanel.getCamera().getTransform() + .getRotation().toAngles(); + baseYaw = angles[0]; + basePitch = angles[1]; + } + + private static boolean nearlyEqual(final Quaternion a, + final Quaternion b) { + return Math.abs(a.w - b.w) < SAME_ROTATION_EPSILON + && Math.abs(a.x - b.x) < SAME_ROTATION_EPSILON + && Math.abs(a.y - b.y) < SAME_ROTATION_EPSILON + && Math.abs(a.z - b.z) < SAME_ROTATION_EPSILON; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTracker.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTracker.java new file mode 100644 index 0000000..7187850 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTracker.java @@ -0,0 +1,306 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.headtrack; + +import com.sun.jna.Memory; + +/** + * Head orientation tracker built on the RayNeo glasses IMU. + * + *

A daemon thread reads 500 Hz IMU frames and fuses them into yaw, + * pitch and roll angles with a complementary filter: gyroscope + * integration for responsiveness, accelerometer gravity as the long-term + * pitch/roll reference. Yaw has no absolute reference (the magnetometer + * is too noisy indoors near a laptop), so it drifts slowly — call + * {@link #recenter()} (Scroll Lock in {@link HeadLookController}) + * whenever the view feels off-center.

+ * + *

Sensor axes, established by calibration capture: gyro[0]/X = pitch + * (nod), gyro[1]/Y = yaw (left/right turn), gyro[2]/Z = roll + * (ear-to-shoulder tilt).

+ * + *

Drift control ("standstill perceived as slow motion"):

+ *
    + *
  • Startup calibration only accepts samples taken while the + * glasses are stationary — moving samples would poison the bias + * estimate with phantom rotation. Putting the glasses on a desk + * for the first second gives the cleanest estimate, but a held + * head passes the stillness gate too.
  • + *
  • While running, the bias is continuously re-estimated whenever + * the head is stationary (fast EMA, ~0.5s time constant).
  • + *
  • Residual rates below {@value #DEADBAND_DPS} dps after bias + * subtraction are integrated as exactly zero — sensor noise can + * never accumulate into visible rotation.
  • + *
+ */ +public final class HeadTracker { + + /** Sensor axis indices. */ + private static final int AXIS_PITCH = 0; + private static final int AXIS_YAW = 1; + private static final int AXIS_ROLL = 2; + + private static final double DPS_TO_RAD = Math.PI / 180.0; + + /** Stationary samples needed for the initial bias estimate. */ + private static final int CALIBRATION_SAMPLES = 500; + + /** Give up waiting for stillness after this long and calibrate anyway. */ + private static final int CALIBRATION_TIMEOUT_SAMPLES = 5000; + + /** Gyro magnitude below which the head counts as stationary (dps). */ + private static final double STATIONARY_THRESHOLD_DPS = 0.5; + + /** + * Rates below this after bias subtraction are treated as zero. + * Kills the slow phantom rotation that gyro noise would otherwise + * integrate into. + */ + private static final double DEADBAND_DPS = 0.07; + + /** Complementary filter weight: accel correction per sample. */ + private static final double ACCEL_BLEND = 0.02; + + /** Bias re-estimation speed while stationary (~0.5s time constant). */ + private static final double BIAS_BLEND = 0.004; + + /** Output smoothing (exponential moving average per sample). */ + private static final double OUTPUT_SMOOTHING = 0.35; + + private final RayNeoHid device; + private final Thread readerThread; + private final Memory frame = new Memory(RayNeoHid.FRAME_SIZE); + + private final double[] gyroBias = new double[3]; + private int calibrationSamples; + private int calibrationAttempts; + + private double fusedYaw, fusedPitch, fusedRoll; + private double centerYaw, centerPitch; + private double smoothYaw, smoothPitch; + private int lastTick; + private long lastSampleNanos; + private boolean hasTick; + + private volatile boolean running = true; + private volatile boolean calibrated; + + // diagnostics (all written on the reader thread, read anywhere) + private volatile int framesReceived; + private volatile int lastTickValue; + private volatile double lastDtMillis; + private volatile double maxAbsGyroDps; + + public HeadTracker(final RayNeoHid device) { + this.device = device; + readerThread = new Thread(this::readLoop, "rayneo-head-tracker"); + readerThread.setDaemon(true); + } + + public void start() { + device.startImu(); + readerThread.start(); + final Thread watchdog = new Thread(this::watchdogLoop, + "rayneo-imu-watchdog"); + watchdog.setDaemon(true); + watchdog.start(); + } + + /** + * The glasses occasionally answer IMU-ON with a stream of frozen + * frames (tick not advancing). If frames stall or the tick freezes, + * re-send the enable sequence. + */ + private void watchdogLoop() { + int lastFrames = 0; + int lastTickSeen = 0; + while (running) { + try { + Thread.sleep(2000); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + final int frames = framesReceived; + final int tick = lastTickValue; + if (frames > 10 && frames == lastFrames) { + System.err.println("head tracker: frame stream stalled, " + + "re-enabling IMU"); + device.startImu(); + } else if (frames > 1000 && tick == lastTickSeen) { + System.err.println("head tracker: tick frozen (stale " + + "sensor data), re-enabling IMU"); + device.startImu(); + } + lastFrames = frames; + lastTickSeen = tick; + } + } + + /** + * One-line diagnostic snapshot: frame flow, tick, integration health. + */ + public String getDebugString() { + return String.format( + "frames=%d tick=%d dt=%.2fms maxGyro=%.1fdps " + + "calibrated=%b yaw=%.2fdeg pitch=%.2fdeg", + framesReceived, lastTickValue, lastDtMillis, + maxAbsGyroDps, calibrated, Math.toDegrees(getLookYaw()), + Math.toDegrees(getLookPitch())); + } + + /** + * Makes the current head orientation the new "straight ahead". + */ + public synchronized void recenter() { + centerYaw = fusedYaw; + centerPitch = fusedPitch; + smoothYaw = 0; + smoothPitch = 0; + } + + /** Head yaw relative to the last recenter, radians. */ + public synchronized double getLookYaw() { + return smoothYaw; + } + + /** Head pitch relative to the last recenter, radians. */ + public synchronized double getLookPitch() { + return smoothPitch; + } + + /** False until the initial gyro bias calibration has finished. */ + public boolean isCalibrated() { + return calibrated; + } + + /** True while the reader thread is alive and frames may be flowing. */ + public boolean isRunning() { + return running && readerThread.isAlive(); + } + + public void stop() { + running = false; + try { + readerThread.join(500); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + device.close(); + } + + private void readLoop() { + while (running) { + final int type = device.readFrame(frame); + if (type < 0) { + if (running) + System.err.println("head tracker: device read failed " + + "(unplugged?), stopping"); + running = false; + return; + } + if (type == RayNeoHid.TYPE_IMU) + onSample(); + } + running = false; + } + + private void onSample() { + final float gx = frame.getFloat(16); + final float gy = frame.getFloat(20); + final float gz = frame.getFloat(24); + final float ax = frame.getFloat(4); + final float ay = frame.getFloat(8); + final float az = frame.getFloat(12); + final int tick = frame.getInt(40); + + framesReceived++; + lastTickValue = tick; + final double absGyro = Math.sqrt(gx * gx + gy * gy + gz * gz); + if (absGyro > maxAbsGyroDps) + maxAbsGyroDps = absGyro; + + if (!calibrated) { + calibrate(gx, gy, gz, ax, ay, az, tick, absGyro); + return; + } + + final double dt; + if (hasTick) { + // The device tick is NOT microseconds (measured: ~50µs per + // unit) — useless for integration timing, kept only as a + // liveness signal for the watchdog. Wall clock at 500Hz is + // fine; jitter is smoothed by the output EMA. + dt = Math.min(Math.max((System.nanoTime() - lastSampleNanos) + / 1_000_000_000.0, 0), 0.1); + } else { + dt = 0.002; + } + lastDtMillis = dt * 1000; + lastSampleNanos = System.nanoTime(); + lastTick = tick; + hasTick = true; + + if (absGyro < STATIONARY_THRESHOLD_DPS) + for (int i = 0; i < 3; i++) { + final double g = i == AXIS_PITCH ? gx : i == AXIS_YAW ? gy : gz; + gyroBias[i] += (g - gyroBias[i]) * BIAS_BLEND; + } + + synchronized (this) { + fusedPitch += deadband(gx - gyroBias[AXIS_PITCH]) * DPS_TO_RAD * dt; + fusedYaw += deadband(gy - gyroBias[AXIS_YAW]) * DPS_TO_RAD * dt; + fusedRoll += deadband(gz - gyroBias[AXIS_ROLL]) * DPS_TO_RAD * dt; + + // gravity reference: pitch rotates around sensor X (mixes the + // Y/Z gravity components), roll around sensor Z (mixes X/Y) + final double accelPitch = Math.atan2(az, ay); + final double accelRoll = Math.atan2(-ax, ay); + fusedPitch += (accelPitch - fusedPitch) * ACCEL_BLEND; + fusedRoll += (accelRoll - fusedRoll) * ACCEL_BLEND; + + final double yaw = fusedYaw - centerYaw; + final double pitch = fusedPitch - centerPitch; + smoothYaw += (yaw - smoothYaw) * OUTPUT_SMOOTHING; + smoothPitch += (pitch - smoothPitch) * OUTPUT_SMOOTHING; + } + } + + /** + * Gyro bias calibration: only stationary samples contribute — if + * the user is already turning, those samples would poison the bias + * with several dps of phantom drift. Give up after ~10s and accept + * whatever we have. + */ + private void calibrate(final float gx, final float gy, final float gz, + final float ax, final float ay, final float az, + final int tick, final double absGyro) { + calibrationAttempts++; + if (absGyro < STATIONARY_THRESHOLD_DPS + || calibrationAttempts > CALIBRATION_TIMEOUT_SAMPLES) { + gyroBias[AXIS_PITCH] += gx; + gyroBias[AXIS_YAW] += gy; + gyroBias[AXIS_ROLL] += gz; + calibrationSamples++; + } + if (calibrationSamples >= CALIBRATION_SAMPLES) { + for (int i = 0; i < 3; i++) + gyroBias[i] /= calibrationSamples; + // jump-start the tilt estimate from gravity; otherwise the + // filter would take a second to converge and the boot + // recenter would capture the unconverged zero + fusedPitch = Math.atan2(az, ay); + fusedRoll = Math.atan2(-ax, ay); + calibrated = true; + lastTick = tick; + lastSampleNanos = System.nanoTime(); + hasTick = true; + } + } + + private static double deadband(final double rateDps) { + return Math.abs(rateDps) < DEADBAND_DPS ? 0 : rateDps; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTrackingManager.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTrackingManager.java new file mode 100644 index 0000000..bda7ff8 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTrackingManager.java @@ -0,0 +1,114 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.headtrack; + +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; + +/** + * Hot-plug manager for XR glasses head tracking. Polls for RayNeo + * glasses every two seconds: + * + *
    + *
  • glasses plugged in → open the HID pipe, start a + * {@link HeadTracker}, attach a {@link HeadLookController} to the + * view's frame listeners
  • + *
  • glasses unplugged (tracker read fails) → stop the tracker and + * detach the controller, so the camera is no longer driven by a + * dead device
  • + *
  • plugged back in → fresh tracker, fresh boot recenter
  • + *
+ * + *

Permission failures are reported once, then retried silently — + * the user may install the udev rule while the application is running.

+ */ +public final class HeadTrackingManager { + + private static final long POLL_INTERVAL_MS = 2000; + + private final ViewPanel viewPanel; + private final Thread thread; + + private volatile boolean running = true; + private HeadTracker tracker; + private HeadLookController controller; + private boolean failureReported; + + public HeadTrackingManager(final ViewPanel viewPanel) { + this.viewPanel = viewPanel; + thread = new Thread(this::pollLoop, "head-tracking-hotplug"); + thread.setDaemon(true); + } + + public void start() { + thread.start(); + } + + /** Active tracker, or null when no glasses are connected. */ + public HeadTracker getTracker() { + return tracker; + } + + public void stop() { + running = false; + try { + thread.join(1000); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + disconnect(); + } + + private void pollLoop() { + while (running) { + poll(); + try { + Thread.sleep(POLL_INTERVAL_MS); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private void poll() { + if (tracker != null && !tracker.isRunning()) { + System.out.println("head tracking: glasses disconnected"); + disconnect(); + } + if (tracker != null) + return; + + try { + final RayNeoHid glasses = RayNeoHid.open(); + if (glasses == null) + return; + tracker = new HeadTracker(glasses); + tracker.start(); + controller = new HeadLookController(tracker, viewPanel); + viewPanel.addFrameListener(controller); + failureReported = false; + System.out.println("head tracking: RayNeo glasses detected, " + + "calibrating (hold still for a second)"); + } catch (final Exception e) { + tracker = null; + if (!failureReported) { + failureReported = true; + System.err.println("head tracking unavailable: " + + e.getMessage()); + } + } + } + + private void disconnect() { + if (controller != null) { + viewPanel.removeFrameListener(controller); + controller = null; + } + if (tracker != null) { + tracker.stop(); + tracker = null; + } + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/RayNeoHid.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/RayNeoHid.java new file mode 100644 index 0000000..c7a47a8 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/RayNeoHid.java @@ -0,0 +1,149 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.headtrack; + +import com.sun.jna.Library; +import com.sun.jna.Memory; +import com.sun.jna.Native; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Raw HID transport for RayNeo AR glasses (T&A Mobile Phones, + * VID 1BBB, PID AF50) via Linux hidraw. + * + *

IMU sample layout inside a 0x65 frame (all little-endian):

+ *
    + *
  • float acc[3] at offset 4 (m/s²)
  • + *
  • float gyro[3] at offset 16 (degrees/second)
  • + *
  • float temperature at 28, float magnet[0..1] at 32
  • + *
  • uint32 tick at 40 (NOT microseconds — ~50µs per unit; use only + * as a liveness signal, never for integration timing)
  • + *
  • float psensor at 44, float lsensor at 48, float magnet[2] at 52
  • + *
+ * + *

Startup sequence that yields live data: psensor enable (0x38) THEN + * IMU on (0x01). Without the psensor enable the glasses may stream + * frozen sensor values.

+ * + *

Permissions: /dev/hidraw* is root-only by default. Install a udev + * rule (e.g. {@code 99-rayneo-glasses.rules} with MODE="0666" for + * VID 1BBB PID AF50) to allow user access.

+ */ +public final class RayNeoHid implements AutoCloseable { + + public static final int FRAME_SIZE = 64; + + private static final int O_RDWR = 0x02; + private static final byte MAGIC_OUT = 0x66; + private static final byte MAGIC_IN = (byte) 0x99; + + /** Frame type: IMU sample. */ + public static final int TYPE_IMU = 0x65; + + private static final int CMD_DEVICE_INFO = 0x00; + private static final int CMD_IMU_ON = 0x01; + private static final int CMD_IMU_OFF = 0x02; + private static final int CMD_PSENSOR_ENABLE = 0x38; + + private interface CLib extends Library { + CLib INSTANCE = Native.load("c", CLib.class); + + int open(String path, int flags); + + int close(int fd); + + int read(int fd, Memory buffer, int count); + + int write(int fd, Memory buffer, int count); + } + + private final int fd; + private final Memory frameBuffer = new Memory(FRAME_SIZE); + + private RayNeoHid(final int fd) { + this.fd = fd; + } + + /** + * Finds the hidraw node of the glasses by scanning sysfs uevent data, + * then opens it. Returns null when the glasses are not connected. + */ + public static RayNeoHid open() throws IOException { + final Path hidrawDir = Path.of("/sys/class/hidraw"); + if (!Files.isDirectory(hidrawDir)) + return null; + + try (DirectoryStream nodes = Files.newDirectoryStream(hidrawDir)) { + for (final Path node : nodes) { + final Path uevent = node.resolve("device/uevent"); + if (!Files.isRegularFile(uevent)) + continue; + final String content = Files.readString(uevent); + if (content.contains("00001BBB") && content.contains("0000AF50")) { + final Path dev = Path.of("/dev", node.getFileName().toString()); + final int fd = CLib.INSTANCE.open(dev.toString(), O_RDWR); + if (fd < 0) + throw new IOException("cannot open " + dev + + " (permissions? install udev rule " + + "99-rayneo-glasses.rules)"); + return new RayNeoHid(fd); + } + } + } + return null; + } + + /** + * Enables the proximity sensor pipeline and starts IMU streaming. + * Both commands are required; psensor-first ordering matters. + */ + public void startImu() { + sendCommand(CMD_PSENSOR_ENABLE, 0); + sendCommand(CMD_IMU_ON, 0); + } + + /** + * Stops IMU streaming. + */ + public void stopImu() { + sendCommand(CMD_IMU_OFF, 0); + } + + private void sendCommand(final int command, final int value) { + synchronized (frameBuffer) { + frameBuffer.clear(); + frameBuffer.setByte(0, MAGIC_OUT); + frameBuffer.setByte(1, (byte) command); + frameBuffer.setByte(2, (byte) value); + CLib.INSTANCE.write(fd, frameBuffer, FRAME_SIZE); + } + } + + /** + * Blocking read of one 64-byte frame. Returns the frame type byte + * (e.g. {@link #TYPE_IMU}) or -1 on error/closed device. The frame + * bytes are left in the given buffer for the caller to parse. + */ + public int readFrame(final Memory out) { + final int n = CLib.INSTANCE.read(fd, out, FRAME_SIZE); + if (n != FRAME_SIZE || out.getByte(0) != MAGIC_IN) + return -1; + return out.getByte(1) & 0xFF; + } + + @Override + public void close() { + try { + stopImu(); + } catch (final Exception ignored) { + // device may already be unplugged + } + CLib.INSTANCE.close(fd); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/Connexion3D.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/Connexion3D.java new file mode 100644 index 0000000..bcd13c0 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/Connexion3D.java @@ -0,0 +1,48 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.humaninput; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; + +/** + * I have Space Mouse Compact 3D Connexion mouse: https://3dconnexion.com/us/product/spacemouse-compact/ + * + * I discovered that it is possible to read raw data from it by reading /dev/hidraw4 file. + * + * TODO: reverse engineer the data format and implement a driver for it. + */ + +public class Connexion3D { + + /** + * Creates a new Connexion3D instance. + */ + public Connexion3D() { + } + + /** + * Reads raw data from the 3Dconnexion device for testing purposes. + * + * @param args command line arguments (ignored) + * @throws IOException if the device cannot be read + */ + public static void main(final String[] args) throws IOException { + + final BufferedReader in = new BufferedReader(new FileReader( + "/dev/hidraw4")); + + + // for testing purposes + while (true) { + System.out.print(in.read() + " "); + System.out.println("\n"); + } + + // in.close(); + + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/InputManager.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/InputManager.java new file mode 100644 index 0000000..cd7db6c --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/InputManager.java @@ -0,0 +1,378 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.humaninput; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.gui.Camera; +import eu.svjatoslav.aukio.e3d.gui.FrameListener; +import eu.svjatoslav.aukio.e3d.gui.ViewFrame; +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; +import eu.svjatoslav.aukio.e3d.math.Quaternion; + +import java.awt.*; +import java.awt.event.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Manages mouse and keyboard input for the 3D view. + * + *

Handles mouse/keyboard events, tracks pressed keys and mouse state, + * and forwards events to the appropriate handlers. Also provides default camera + * control via mouse dragging (look around) and mouse wheel (vertical movement).

+ * + * @see ViewPanel#getInputManager() + */ +public class InputManager implements + MouseMotionListener, KeyListener, MouseListener, MouseWheelListener, FrameListener { + + private final Map pressedKeysToPressedTimeMap = new HashMap<>(); + private final List detectedMouseEvents = new ArrayList<>(); + private final List detectedKeyEvents = new ArrayList<>(); + private final Point2D mouseDelta = new Point2D(); + private final MouseEvent reusableHoverEvent = new MouseEvent(new Point2D(), 0); + private final Point2D reusableMouseLocation = new Point2D(); + private final ViewPanel viewPanel; + private int wheelVerticalUnits = 0; + private int wheelHorizontalUnits = 0; + private Point2D oldMouseCoordinatesWhenDragging; + private Point2D currentMouseLocation; + private boolean mouseMoved; + private boolean mouseWithinWindow = false; + private double cameraYaw = 0; + private double cameraPitch = 0; + private double cameraRoll = 0; + + /** + * Creates an input manager attached to the given view panel. + * + * @param viewPanel the view panel to receive input from + */ + public InputManager(final ViewPanel viewPanel) { + this.viewPanel = viewPanel; + bind(viewPanel); + } + + /** + * Processes accumulated input events and updates camera based on mouse drag/wheel. + * + * @param viewPanel the view panel + * @param millisecondsSinceLastFrame time since last frame (unused) + * @return {@code true} if a view repaint is needed + */ + @Override + public boolean onFrame(final ViewPanel viewPanel, final int millisecondsSinceLastFrame) { + boolean viewUpdateNeeded = handleKeyboardEvents(); + viewUpdateNeeded |= handleMouseClicksAndHover(viewPanel); + viewUpdateNeeded |= handleMouseDragging(); + viewUpdateNeeded |= handleMouseScrolling(); + return viewUpdateNeeded; + } + + /** + * Binds this input manager to listen for events on the given component. + * Also registers a global {@link KeyEventDispatcher} so keyboard shortcuts + * (F11, F12, SHIFT+F11) work even when the ViewPanel does not have focus. + * + * @param component the component to attach listeners to + */ + private void bind(final Component component) { + component.addMouseMotionListener(this); + component.addKeyListener(this); + component.addMouseListener(this); + component.addMouseWheelListener(this); + } + + /** + * Processes all accumulated keyboard events and forwards them to the current focus owner. + * + * @return {@code true} if any event handler requested a repaint + */ + private boolean handleKeyboardEvents() { + final KeyboardInputHandler currentFocusOwner = viewPanel.getKeyboardFocusStack().getCurrentFocusOwner(); + + if (currentFocusOwner == null) + return false; + + boolean viewUpdateNeeded = false; + synchronized (detectedKeyEvents) { + for (int i = 0; i < detectedKeyEvents.size(); i++) + viewUpdateNeeded |= processKeyEvent(currentFocusOwner, detectedKeyEvents.get(i)); + detectedKeyEvents.clear(); + } + return viewUpdateNeeded; + } + + /** + * Processes a single keyboard event by dispatching to the focus owner. + * + * @param currentFocusOwner the component that currently has keyboard focus + * @param keyEvent the keyboard event to process + * @return {@code true} if the handler requested a repaint + */ + private boolean processKeyEvent(KeyboardInputHandler currentFocusOwner, KeyEvent keyEvent) { + switch (keyEvent.getID()) { + case KeyEvent.KEY_PRESSED: + return currentFocusOwner.keyPressed(keyEvent, viewPanel); + + case KeyEvent.KEY_RELEASED: + return currentFocusOwner.keyReleased(keyEvent, viewPanel); + } + return false; + } + + /** + * Handles mouse clicks and hover detection. + * Sets up the mouse event in the rendering context for shape hit testing. + * + * @param viewPanel the view panel + * @return {@code true} if a repaint is needed + */ + private synchronized boolean handleMouseClicksAndHover(final ViewPanel viewPanel) { + boolean rerenderNeeded = false; + MouseEvent event = findClickLocationToTrace(); + if (event != null) { + rerenderNeeded = true; + } else { + if (mouseMoved) { + mouseMoved = false; + rerenderNeeded = true; + } + + if (currentMouseLocation != null) { + reusableHoverEvent.coordinate.x = currentMouseLocation.x; + reusableHoverEvent.coordinate.y = currentMouseLocation.y; + event = reusableHoverEvent; + } + } + + if (viewPanel.getRenderingContext() != null) + viewPanel.getRenderingContext().setMouseEvent(event); + + return rerenderNeeded; + } + + private MouseEvent findClickLocationToTrace() { + synchronized (detectedMouseEvents) { + if (detectedMouseEvents.isEmpty()) + return null; + + return detectedMouseEvents.remove(0); + } + } + + /** + * Returns whether the specified key is currently pressed. + * + * @param keyCode the key code (from {@link java.awt.event.KeyEvent}) + * @return {@code true} if the key is currently pressed + */ + public boolean isKeyPressed(final int keyCode) { + return pressedKeysToPressedTimeMap.containsKey(keyCode); + } + + @Override + public void keyPressed(final KeyEvent evt) { + if (evt.getKeyCode() == java.awt.event.KeyEvent.VK_F12) { + viewPanel.showDeveloperToolsPanel(); + return; + } + if (evt.getKeyCode() == java.awt.event.KeyEvent.VK_F11) { + final ViewFrame frame = findParentViewFrame(); + if (frame != null) { + if (evt.isShiftDown()) { + // SHIFT+F11: toggle stereo + fullscreen together + final boolean stereoOn = !viewPanel.isStereoModeEnabled(); + viewPanel.setStereoModeEnabled(stereoOn); + frame.setFullscreen(stereoOn); + } else { + // F11: toggle fullscreen only + frame.toggleFullscreen(); + } + } + return; + } + // +/- adjusts stereo IPD (inter-pupillary distance) when stereo is active + if (viewPanel.isStereoModeEnabled()) { + final int code = evt.getKeyCode(); + if (code == java.awt.event.KeyEvent.VK_PLUS + || code == java.awt.event.KeyEvent.VK_EQUALS + || code == java.awt.event.KeyEvent.VK_ADD) { + viewPanel.setStereoIPD(viewPanel.getStereoIPD() + 0.5); + return; + } + if (code == java.awt.event.KeyEvent.VK_MINUS + || code == java.awt.event.KeyEvent.VK_SUBTRACT) { + viewPanel.setStereoIPD(Math.max(0.5, viewPanel.getStereoIPD() - 0.5)); + return; + } + } + synchronized (detectedKeyEvents) { + pressedKeysToPressedTimeMap.put(evt.getKeyCode(), System.currentTimeMillis()); + detectedKeyEvents.add(evt); + } + } + + @Override + public void keyReleased(final KeyEvent evt) { + synchronized (detectedKeyEvents) { + pressedKeysToPressedTimeMap.remove(evt.getKeyCode()); + detectedKeyEvents.add(evt); + } + } + + @Override + public void keyTyped(final KeyEvent e) { + } + + @Override + public void mouseClicked(final java.awt.event.MouseEvent e) { + synchronized (detectedMouseEvents) { + detectedMouseEvents.add(new MouseEvent(e.getX(), e.getY(), e.getButton())); + } + } + + @Override + public void mouseDragged(final java.awt.event.MouseEvent evt) { + reusableMouseLocation.x = evt.getX(); + reusableMouseLocation.y = evt.getY(); + + if (oldMouseCoordinatesWhenDragging == null) { + oldMouseCoordinatesWhenDragging = new Point2D(reusableMouseLocation.x, reusableMouseLocation.y); + return; + } + + mouseDelta.x += reusableMouseLocation.x - oldMouseCoordinatesWhenDragging.x; + mouseDelta.y += reusableMouseLocation.y - oldMouseCoordinatesWhenDragging.y; + + oldMouseCoordinatesWhenDragging.x = reusableMouseLocation.x; + oldMouseCoordinatesWhenDragging.y = reusableMouseLocation.y; + } + + @Override + public void mouseEntered(final java.awt.event.MouseEvent e) { + mouseWithinWindow = true; + } + + @Override + public synchronized void mouseExited(final java.awt.event.MouseEvent e) { + mouseWithinWindow = false; + currentMouseLocation = null; + } + + @Override + public synchronized void mouseMoved(final java.awt.event.MouseEvent e) { + if (currentMouseLocation == null) + currentMouseLocation = new Point2D(e.getX(), e.getY()); + else { + currentMouseLocation.x = e.getX(); + currentMouseLocation.y = e.getY(); + } + mouseMoved = true; + } + + @Override + public void mousePressed(final java.awt.event.MouseEvent e) { + // Extra buttons (mouse back/forward) never produce an AWT CLICKED + // event on Linux (only PRESSED/RELEASED), so they are delivered + // to the component already on press. + if (e.getButton() > 3) { + synchronized (detectedMouseEvents) { + detectedMouseEvents.add( + new MouseEvent(e.getX(), e.getY(), e.getButton())); + } + } + // Initialize camera rotation state from current camera orientation. + // This prevents a jump when the camera was programmatically positioned + // with a non-default rotation before the user started dragging. + final Camera camera = viewPanel.getCamera(); + final double[] angles = camera.getTransform().getRotation().toAngles(); + cameraYaw = angles[0]; + cameraPitch = angles[1]; + cameraRoll = angles[2]; + } + + @Override + public void mouseReleased(final java.awt.event.MouseEvent evt) { + oldMouseCoordinatesWhenDragging = null; + } + + @Override + public void mouseWheelMoved(final java.awt.event.MouseWheelEvent evt) { + // horizontal wheel input arrives as shift-modified vertical + // rotation (AWT convention on all platforms) + if (evt.isShiftDown()) + wheelHorizontalUnits += evt.getWheelRotation(); + else + wheelVerticalUnits += evt.getWheelRotation(); + } + + /** + * Routes scroll wheel input: to the focused GUI component when it + * consumes it, otherwise to the camera (vertical = up/down, + * horizontal = strafe left/right). + */ + private boolean handleMouseScrolling() { + final int vertical = wheelVerticalUnits; + final int horizontal = wheelHorizontalUnits; + wheelVerticalUnits = 0; + wheelHorizontalUnits = 0; + if (vertical == 0 && horizontal == 0) + return false; + + final KeyboardInputHandler focusOwner = viewPanel + .getKeyboardFocusStack().getCurrentFocusOwner(); + if (focusOwner instanceof MouseInteractionController component + && component.mouseWheelMoved(vertical, horizontal)) + // consumed by the focused component + return true; + + final Camera camera = viewPanel.getCamera(); + final double actualAcceleration = 50 * camera.cameraAcceleration * (1 + (camera.getMovementSpeed() / 10)); + camera.getMovementVector().y += (vertical * actualAcceleration); + camera.getMovementVector().x += (horizontal * actualAcceleration); + camera.enforceSpeedLimit(); + return true; + } + + private boolean handleMouseDragging() { + if (mouseDelta.isZero()) { + return false; + } + + cameraYaw -= mouseDelta.x / 50.0; + cameraPitch -= mouseDelta.y / 50.0; + + cameraPitch = Math.max(-Math.PI / 2 + 0.001, + Math.min( Math.PI / 2 - 0.001, cameraPitch)); + + final Camera camera = viewPanel.getCamera(); + camera.getTransform().getRotation().set( + Quaternion.fromAngles(cameraYaw, cameraPitch, cameraRoll)); + + mouseDelta.zero(); + return true; + } + + /** + * Walks up the component hierarchy from the view panel to find the + * parent {@link ViewFrame}, if any. + * + * @return the parent ViewFrame, or {@code null} if the ViewPanel is + * not embedded in a ViewFrame + */ + private ViewFrame findParentViewFrame() { + java.awt.Container parent = viewPanel.getParent(); + while (parent != null) { + if (parent instanceof ViewFrame) + return (ViewFrame) parent; + parent = parent.getParent(); + } + return null; + } + +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardFocusStack.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardFocusStack.java new file mode 100644 index 0000000..dc9360f --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardFocusStack.java @@ -0,0 +1,102 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.humaninput; + +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; + +/** + * Manages keyboard focus for interactive 3D components. + * + *

Exactly one {@link KeyboardInputHandler} has keyboard focus at a time. + * When a component gains focus (e.g., by being clicked), the previous focus + * owner is notified and forgotten. When the component releases focus (ESC or + * middle mouse button), focus falls back to the default handler — never to a + * previously focused component (there is no focus stack).

+ * + *

The default handler is a {@link WorldNavigationUserInputTracker}, which + * handles WASD/arrow-key camera movement while no component has focus.

+ * + *

Focus flow example:

+ *
{@code
+ * // Initial state: WorldNavigationUserInputTracker has focus (camera movement)
+ * // User clicks on a text editor:
+ * focus.pushFocusOwner(textEditor);
+ * // Now textEditor receives keyboard events
+ *
+ * // User presses ESC or middle-clicks the editor:
+ * focus.popFocusOwner();
+ * // Camera movement is active again
+ * }
+ * + * @see KeyboardInputHandler the interface that focus owners must implement + * @see WorldNavigationUserInputTracker default handler for camera navigation + */ +public class KeyboardFocusStack { + + private final ViewPanel viewPanel; + private final WorldNavigationUserInputTracker defaultInputHandler = new WorldNavigationUserInputTracker(); + private KeyboardInputHandler currentUserInputHandler; + + /** + * Creates a new focus manager for the given view panel, with + * {@link WorldNavigationUserInputTracker} as the default focus owner. + * + * @param viewPanel the view panel this focus manager belongs to + */ + public KeyboardFocusStack(final ViewPanel viewPanel) { + this.viewPanel = viewPanel; + currentUserInputHandler = defaultInputHandler; + currentUserInputHandler.focusReceived(viewPanel); + } + + /** + * Returns the handler that currently has keyboard focus. When no + * component is focused, this is the default camera navigation handler. + * + * @return the current focus owner + */ + public KeyboardInputHandler getCurrentFocusOwner() { + return currentUserInputHandler; + } + + /** + * Releases focus from the current focus owner; focus falls back to the + * default camera navigation handler. No previously focused component is + * restored — focus is single-level, not a stack. + */ + public void popFocusOwner() { + if (currentUserInputHandler == defaultInputHandler) + return; + + currentUserInputHandler.focusLost(viewPanel); + currentUserInputHandler = defaultInputHandler; + currentUserInputHandler.focusReceived(viewPanel); + } + + /** + * Gives keyboard focus to the given handler. The previous focus owner is + * notified via {@link KeyboardInputHandler#focusLost} and forgotten. + * + *

If the given handler is already the current focus owner, this method + * does nothing and returns {@code false}.

+ * + * @param newInputHandler the handler to receive keyboard focus + * @return {@code true} if the view needs to be repainted as a result + */ + public boolean pushFocusOwner(final KeyboardInputHandler newInputHandler) { + boolean updateNeeded = false; + + if (currentUserInputHandler == newInputHandler) + return false; + + if (currentUserInputHandler != null) + updateNeeded = currentUserInputHandler.focusLost(viewPanel); + + currentUserInputHandler = newInputHandler; + updateNeeded |= currentUserInputHandler.focusReceived(viewPanel); + + return updateNeeded; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardHelper.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardHelper.java new file mode 100644 index 0000000..a2d32a2 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardHelper.java @@ -0,0 +1,124 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.humaninput; + +import java.awt.event.InputEvent; +import java.util.HashSet; +import java.util.Set; + +/** + * Utility class providing keyboard key code constants and modifier detection methods. + * + *

Provides named constants for common key codes and static helper methods + * to check whether modifier keys (Ctrl, Alt, Shift) are pressed in a given + * event modifier mask.

+ * + *

Usage example:

+ *
{@code
+ * public boolean keyPressed(KeyEvent event, ViewPanel viewPanel) {
+ *     if (event.getKeyCode() == KeyboardHelper.ENTER) {
+ *         // Handle Enter key
+ *     }
+ *     if (KeyboardHelper.isCtrlPressed(event.getModifiersEx())) {
+ *         // Handle Ctrl+key combination
+ *     }
+ *     return true;
+ * }
+ * }
+ * + * @see KeyboardInputHandler the interface for receiving keyboard events + */ +public class KeyboardHelper { + + /** + * Private constructor to prevent instantiation of this utility class. + */ + private KeyboardHelper() { + } + + /** Key code for the Tab key. */ + public static final int TAB = 9; + /** Key code for the Down arrow key. */ + public static final int DOWN = 40; + /** Key code for the Up arrow key. */ + public static final int UP = 38; + /** Key code for the Right arrow key. */ + public static final int RIGHT = 39; + /** Key code for the Left arrow key. */ + public static final int LEFT = 37; + /** Key code for the Page Down key. */ + public static final int PGDOWN = 34; + /** Key code for the Page Up key. */ + public static final int PGUP = 33; + /** Key code for the Home key. */ + public static final int HOME = 36; + /** Key code for the End key. */ + public static final int END = 35; + /** Key code for the Delete key. */ + public static final int DEL = 127; + /** Key code for the Enter/Return key. */ + public static final int ENTER = 10; + /** Key code for the Backspace key. */ + public static final int BACKSPACE = 8; + /** Key code for the Escape key. */ + public static final int ESC = 27; + /** Key code for the Shift key. */ + public static final int SHIFT = 16; + + private static final Set nonText; + + static { + nonText = new HashSet<>(); + nonText.add(DOWN); + nonText.add(UP); + nonText.add(LEFT); + nonText.add(RIGHT); + + nonText.add(SHIFT); + nonText.add(ESC); + } + + /** + * Checks if the Alt key is pressed in the given modifier mask. + * + * @param modifiersEx the extended modifier mask from {@link java.awt.event.KeyEvent#getModifiersEx()} + * @return {@code true} if Alt is pressed + */ + public static boolean isAltPressed(final int modifiersEx) { + return (modifiersEx | InputEvent.ALT_DOWN_MASK) == modifiersEx; + } + + /** + * Checks if the Ctrl key is pressed in the given modifier mask. + * + * @param modifiersEx the extended modifier mask from {@link java.awt.event.KeyEvent#getModifiersEx()} + * @return {@code true} if Ctrl is pressed + */ + public static boolean isCtrlPressed(final int modifiersEx) { + return (modifiersEx | InputEvent.CTRL_DOWN_MASK) == modifiersEx; + } + + /** + * Checks if the Shift key is pressed in the given modifier mask. + * + * @param modifiersEx the extended modifier mask from {@link java.awt.event.KeyEvent#getModifiersEx()} + * @return {@code true} if Shift is pressed + */ + public static boolean isShiftPressed(final int modifiersEx) { + return (modifiersEx | InputEvent.SHIFT_DOWN_MASK) == modifiersEx; + } + + /** + * Determines whether the given key code represents a text-producing key + * (as opposed to navigation or modifier keys like arrows, Shift, Escape). + * + * @param keyCode the key code to check + * @return {@code true} if the key produces text input + */ + public static boolean isText(final int keyCode) { + return !nonText.contains(keyCode); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardInputHandler.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardInputHandler.java new file mode 100644 index 0000000..e6ed27b --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardInputHandler.java @@ -0,0 +1,54 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.humaninput; + +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; + +import java.awt.event.KeyEvent; + +/** + * This is the process: + *

+ * 1. Component receives focus, perhaps because user clicked on it with the mouse. + * 2. Now component will receive user key press and release events from the keyboard. + * 3. Component loses focus. Perhaps user chose another component to interact with. + */ +public interface KeyboardInputHandler { + + /** + * Called when the component loses keyboard focus. + * + * @param viewPanel the view panel that owns this handler + * @return {@code true} if view needs to be re-rendered + */ + boolean focusLost(ViewPanel viewPanel); + + /** + * Called when the component receives keyboard focus. + * + * @param viewPanel the view panel that owns this handler + * @return {@code true} if view needs to be re-rendered + */ + boolean focusReceived(ViewPanel viewPanel); + + /** + * Called when a key is pressed while the component has focus. + * + * @param event the key event + * @param viewPanel the view panel that owns this handler + * @return {@code true} if view needs to be re-rendered + */ + boolean keyPressed(KeyEvent event, ViewPanel viewPanel); + + /** + * Called when a key is released while the component has focus. + * + * @param event the key event + * @param viewPanel the view panel that owns this handler + * @return {@code true} if view needs to be re-rendered + */ + boolean keyReleased(KeyEvent event, ViewPanel viewPanel); + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseEvent.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseEvent.java new file mode 100644 index 0000000..819d9b0 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseEvent.java @@ -0,0 +1,59 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.humaninput; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; + +/** + * Represents mouse event. + */ +public class MouseEvent { + + /** Mouse over (no button pressed). */ + public static final int BUTTON_HOVER = 0; + /** Left mouse button. */ + public static final int BUTTON_LEFT = 1; + /** Middle mouse button. */ + public static final int BUTTON_MIDDLE = 2; + /** Right mouse button. */ + public static final int BUTTON_RIGHT = 3; + /** + * Mouse back button. AWT on Linux reports X buttons 8/9 as 6/7 and + * delivers them only as PRESSED/RELEASED, never CLICKED; other + * platforms may use 4/5 — handle both. + */ + public static final int BUTTON_BACK = 6; + /** Mouse forward button (AWT 7 on Linux = X button 9). */ + public static final int BUTTON_FORWARD = 7; + + /** + * Mouse coordinate in screen space (pixels) relative to top left corner of the screen + * when mouse button was clicked. + */ + public Point2D coordinate; + + /** + * One of {@link #BUTTON_HOVER}, {@link #BUTTON_LEFT}, + * {@link #BUTTON_MIDDLE}, {@link #BUTTON_RIGHT}. + */ + public int button; + + MouseEvent(final int x, final int y, final int button) { + this(new Point2D(x, y), button); + } + + MouseEvent(final Point2D coordinate, final int button) { + this.coordinate = coordinate; + this.button = button; + } + + @Override + public String toString() { + return "MouseEvent{" + + "coordinate=" + coordinate + + ", button=" + button + + '}'; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseInteractionController.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseInteractionController.java new file mode 100644 index 0000000..d0b8800 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseInteractionController.java @@ -0,0 +1,86 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.humaninput; + +/** + * Interface that allows to handle mouse events. + */ +public interface MouseInteractionController { + + /** + * Called when mouse is clicked on component. + * + * @param button the mouse button that was clicked (1 = left, 2 = middle, 3 = right) + * @return {@code true} if view update is needed as a consequence of this mouse click + */ + boolean mouseClicked(int button); + + /** + * Called when mouse is clicked on component, with the exact texture + * coordinates of the clicked point when the hit shape is textured. + * + *

The default implementation ignores the texture coordinates and + * delegates to {@link #mouseClicked(int)}. Components that need to know + * WHERE on their surface the click landed (e.g. to forward the click + * into a captured application window) override this method.

+ * + * @param button the mouse button that was clicked (1 = left, 2 = middle, 3 = right) + * @param textureU texture-space X of the clicked point in primary-texture + * pixels, or {@link Double#NaN} when the hit shape has no texture + * @param textureV texture-space Y of the clicked point in primary-texture + * pixels, or {@link Double#NaN} when the hit shape has no texture + * @return {@code true} if view update is needed as a consequence of this mouse click + */ + default boolean mouseClicked(final int button, final double textureU, + final double textureV) { + return mouseClicked(button); + } + + /** + * Called when the mouse wheel is turned while this component has + * keyboard focus. Both axes are reported: horizontal wheel input + * arrives from AWT as shift-modified vertical rotation and is + * separated by the input manager. + * + *

Returning {@code false} lets the wheel fall through to the + * default camera movement.

+ * + * @param verticalUnits wheel notches; positive = wheel away from + * the user (content scrolls down) + * @param horizontalUnits wheel notches; positive = scroll right + * @return {@code true} if the component consumed the scroll + */ + default boolean mouseWheelMoved(final int verticalUnits, + final int horizontalUnits) { + return false; + } + + /** + * Called when the mouse hovers over this component, with the exact + * texture coordinates of the hovered point when the hit shape is + * textured (NaN otherwise). Allows components that forward input into + * a captured application window to track the pointer position. + * + * @return {@code true} if view update is needed + */ + default boolean mouseHover(final double textureU, final double textureV) { + return false; + } + + /** + * Called when mouse gets over given component. + * + * @return true if view update is needed as a consequence of this mouse enter. + */ + boolean mouseEntered(); + + /** + * Called when mouse leaves screen area occupied by component. + * + * @return true if view update is needed as a consequence of this mouse exit. + */ + boolean mouseExited(); + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/WorldNavigationUserInputTracker.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/WorldNavigationUserInputTracker.java new file mode 100644 index 0000000..dcdc78b --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/WorldNavigationUserInputTracker.java @@ -0,0 +1,93 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.humaninput; + +import eu.svjatoslav.aukio.e3d.gui.Camera; +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; +import eu.svjatoslav.aukio.e3d.gui.FrameListener; + +import java.awt.event.KeyEvent; + +/** + * Default keyboard input handler that translates arrow key presses into camera (avatar) + * movement through the 3D world. + * + *

This handler is automatically registered as the default focus owner in the + * {@link KeyboardFocusStack}. It listens for arrow key presses on each frame and + * applies acceleration to the avatar's movement vector accordingly:

+ *
    + *
  • Up arrow - move forward (positive Z)
  • + *
  • Down arrow - move backward (negative Z)
  • + *
  • Right arrow - move right (positive X)
  • + *
  • Left arrow - move left (negative X)
  • + *
+ * + *

Movement acceleration scales with the time delta between frames for smooth, + * frame-rate-independent navigation. It also scales with current speed for a natural + * acceleration curve.

+ * + * @see KeyboardFocusStack the focus system that manages this handler + * @see Camera the camera/viewer that this handler moves + */ +public class WorldNavigationUserInputTracker implements KeyboardInputHandler, FrameListener { + + /** + * Creates a new world navigation input tracker. + */ + public WorldNavigationUserInputTracker() { + } + + @Override + public boolean onFrame(final ViewPanel viewPanel, + final int millisecondsSinceLastFrame) { + + final InputManager inputManager = viewPanel.getInputManager(); + + final Camera camera = viewPanel.getCamera(); + + final double actualAcceleration = (long) millisecondsSinceLastFrame + * camera.cameraAcceleration + * (1 + (camera.getMovementSpeed() / 10)); + + if (inputManager.isKeyPressed(KeyboardHelper.UP)) + camera.getMovementVector().z += actualAcceleration; + + if (inputManager.isKeyPressed(KeyboardHelper.DOWN)) + camera.getMovementVector().z -= actualAcceleration; + + if (inputManager.isKeyPressed(KeyboardHelper.RIGHT)) + camera.getMovementVector().x += actualAcceleration; + + if (inputManager.isKeyPressed(KeyboardHelper.LEFT)) + camera.getMovementVector().x -= actualAcceleration; + + camera.enforceSpeedLimit(); + + return false; + } + + @Override + public boolean focusLost(final ViewPanel viewPanel) { + viewPanel.removeFrameListener(this); + return false; + } + + @Override + public boolean focusReceived(final ViewPanel viewPanel) { + viewPanel.addFrameListener(this); + return false; + } + + @Override + public boolean keyPressed(final KeyEvent event, final ViewPanel viewContext) { + return false; + } + + @Override + public boolean keyReleased(final KeyEvent event, final ViewPanel viewContext) { + return false; + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/package-info.java new file mode 100644 index 0000000..041b06a --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/package-info.java @@ -0,0 +1,7 @@ +/** + * Provides input device tracking (keyboard, mouse) and event forwarding to virtual components. + * + * @see eu.svjatoslav.aukio.e3d.gui.humaninput.InputManager + * @see eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardFocusStack + */ +package eu.svjatoslav.aukio.e3d.gui.humaninput; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/package-info.java new file mode 100644 index 0000000..1dc70e4 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/package-info.java @@ -0,0 +1,24 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Graphical user interface components for the Aukio 3D engine. + * + *

This package provides the primary integration points for embedding 3D rendering + * into Java applications using Swing/AWT.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.aukio.e3d.gui.ViewPanel} - The main rendering surface (JPanel)
  • + *
  • {@link eu.svjatoslav.aukio.e3d.gui.ViewFrame} - A JFrame with embedded ViewPanel
  • + *
  • {@link eu.svjatoslav.aukio.e3d.gui.Camera} - Represents the viewer's position and orientation
  • + *
  • {@link eu.svjatoslav.aukio.e3d.gui.DeveloperTools} - Debugging and profiling utilities
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.gui.ViewPanel + * @see eu.svjatoslav.aukio.e3d.gui.Camera + */ + +package eu.svjatoslav.aukio.e3d.gui; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseController.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseController.java new file mode 100644 index 0000000..89469c6 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseController.java @@ -0,0 +1,161 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.spacemouse; + +import eu.svjatoslav.aukio.e3d.gui.Camera; +import eu.svjatoslav.aukio.e3d.gui.FrameListener; +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; +import eu.svjatoslav.aukio.e3d.math.Matrix3x3; +import eu.svjatoslav.aukio.e3d.math.Quaternion; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; + +/** + * Applies SpaceNavigator 6DOF input to the camera, every frame: + * + *
    + *
  • push/pull/slide the cap → camera moves DIRECTLY, proportional + * to deflection (the cap senses pressure, unlike a keyboard): + * velocity = deflection × top speed, applied to the camera + * position every frame. Release the cap and movement stops + * instantly — no acceleration ramp, no coasting
  • + *
  • tilt/twist the cap → camera yaw/pitch. Applied as an + * incremental delta on top of the current orientation, so it + * composes with head tracking (HeadLookController folds the + * delta into its base pose, same as mouse drags) and with mouse + * drag look
  • + *
  • left button → brake (zero the keyboard/wheel movement vector)
  • + *
+ * + *

Cap roll is measured but deliberately not applied — consistent + * with head tracking, the world horizon stays level.

+ */ +public final class SpaceMouseController implements FrameListener { + + /** + * Raw-axis → camera sign mapping. Hardware conventions are + * documented in {@link SpaceNavigatorHid}. Camera conventions: + * movementVector.x+ = strafe right, .z+ = forward, .y+ = up; + * yaw+ = turn left, pitch+ = look up. + */ + private static final double STRAFE_SIGN = 1.0; // push right → strafe right + private static final double FORWARD_SIGN = -1.0; // push away → forward (live-verified) + private static final double VERTICAL_SIGN = 1.0; // press down → move down (live-verified) + private static final double YAW_SIGN = -1.0; // twist clockwise → turn right + private static final double PITCH_SIGN = 1.0; // tilt top away → look up (live-verified) + + /** Raw counts below this are treated as "hands off". */ + private static final int DEADBAND = 1; + + /** Camera speed at full cap deflection, as a multiple of the + * keyboard top speed ({@link Camera#SPEED_LIMIT}). 3.0 = direct + * drive tops out at 3x the keyboard's capped speed (2026-09-10 — + * keyboard cap was the hidden ceiling behind "still too slow"). */ + private static final double TRANSLATION_SPEED_FACTOR = 5.0; + + /** Degrees of view yaw per frame at full cap twist + * (2x pitch 2026-09-10 — twist felt under-responsive). */ + private static final double ROTATION_YAW_DEGREES = 3.0; + + /** Degrees of view pitch per frame at full cap tilt. */ + private static final double ROTATION_PITCH_DEGREES = 1.5; + + private final SpaceNavigatorHid device; + private final ViewPanel viewPanel; + + private double sensitivity = 5.0; + private boolean leftButtonWasPressed; + + public SpaceMouseController(final SpaceNavigatorHid device, + final ViewPanel viewPanel) { + this.device = device; + this.viewPanel = viewPanel; + } + + public double getSensitivity() { + return sensitivity; + } + + public void setSensitivity(final double sensitivity) { + this.sensitivity = sensitivity; + } + + @Override + public boolean onFrame(final ViewPanel viewPanel, + final int millisecondsSinceLastFrame) { + if (!device.isRunning()) + return false; + + final Camera camera = viewPanel.getCamera(); + boolean changed = false; + + // Frame-rate independent scaling, 60 fps baseline. + final double dt = millisecondsSinceLastFrame / 16.6667; + + final double strafe = shaped(device.getTx()) * STRAFE_SIGN; + final double forward = shaped(device.getTy()) * FORWARD_SIGN; + final double vertical = shaped(device.getTz()) * VERTICAL_SIGN; + if (strafe != 0 || forward != 0 || vertical != 0) { + // Direct proportional drive: deflection maps to velocity + // (full deflection = keyboard top speed), applied straight + // to the camera position. No movement vector, no friction — + // releasing the cap stops the camera this very frame. + final Matrix3x3 m = camera.getTransform().getRotation() + .toMatrix(); + final Point3D location = camera.getTransform() + .getTranslation(); + final double step = TRANSLATION_SPEED_FACTOR + * Camera.SPEED_LIMIT * Camera.SPEED_MULTIPLIER + * millisecondsSinceLastFrame * sensitivity; + location.x += (m.m20 * forward + m.m00 * strafe) * step; + location.y += (m.m21 * forward + m.m01 * strafe) * step; + location.z += (m.m22 * forward + m.m02 * strafe) * step; + location.y += vertical * step; + changed = true; + } + + final double yawDelta = shaped(device.getRz()) * YAW_SIGN; + final double pitchDelta = shaped(device.getRx()) * PITCH_SIGN; + if (yawDelta != 0 || pitchDelta != 0) { + final double[] angles = camera.getTransform().getRotation() + .toAngles(); + double yaw = angles[0] + yawDelta + * Math.toRadians(ROTATION_YAW_DEGREES) + * sensitivity * dt; + double pitch = angles[1] + pitchDelta + * Math.toRadians(ROTATION_PITCH_DEGREES) + * sensitivity * dt; + pitch = Math.max(-Math.PI / 2 + 0.001, + Math.min(Math.PI / 2 - 0.001, pitch)); + camera.getTransform().getRotation().set( + Quaternion.fromAngles(yaw, pitch, 0)); + changed = true; + } + + final boolean leftPressed = (device.getButtons() & 1) != 0; + if (leftPressed && !leftButtonWasPressed) { + camera.getMovementVector().x = 0; + camera.getMovementVector().y = 0; + camera.getMovementVector().z = 0; + changed = true; + } + leftButtonWasPressed = leftPressed; + + return changed; + } + + /** + * Deadband + quadratic response: raw axis (−350..350) → −1..1. + * Quadratic keeps fine control near the center while preserving + * full speed at the rim. + */ + private static double shaped(final int raw) { + final int magnitude = Math.abs(raw); + if (magnitude < DEADBAND) + return 0; + final double v = (magnitude - DEADBAND) + / (SpaceNavigatorHid.FULL_DEFLECTION - DEADBAND); + return Math.copySign(v * v, raw); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseManager.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseManager.java new file mode 100644 index 0000000..68f0d0e --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseManager.java @@ -0,0 +1,116 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.spacemouse; + +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; + +/** + * Hot-plug manager for the SpaceNavigator 6DOF mouse. Polls for the + * device every two seconds: + * + *
    + *
  • plugged in → open the HID pipe, attach a + * {@link SpaceMouseController} to the view's frame listeners
  • + *
  • unplugged (read error) → stop and detach
  • + *
  • plugged back in → fresh device instance
  • + *
+ * + *

Permission failures are reported once, then retried silently — + * the user may install the udev rule while the application runs.

+ */ +public final class SpaceMouseManager { + + private static final long POLL_INTERVAL_MS = 2000; + + private final ViewPanel viewPanel; + private final Thread thread; + + private volatile boolean running = true; + private SpaceNavigatorHid device; + private SpaceMouseController controller; + private boolean failureReported; + + public SpaceMouseManager(final ViewPanel viewPanel) { + this.viewPanel = viewPanel; + thread = new Thread(this::pollLoop, "spacemouse-hotplug"); + thread.setDaemon(true); + } + + public void start() { + thread.start(); + } + + /** Active device, or null when no SpaceNavigator is connected. */ + public SpaceNavigatorHid getDevice() { + return device; + } + + /** Active controller, or null when no SpaceNavigator is connected. */ + public SpaceMouseController getController() { + return controller; + } + + public void stop() { + running = false; + try { + thread.join(1000); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + disconnect(); + } + + private void pollLoop() { + while (running) { + poll(); + try { + Thread.sleep(POLL_INTERVAL_MS); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private void poll() { + if (device != null && !device.isRunning()) { + System.out.println("spacemouse: disconnected"); + disconnect(); + } + if (device != null) + return; + + try { + final SpaceNavigatorHid opened = SpaceNavigatorHid.open(); + if (opened == null) + return; + opened.start(); + device = opened; + controller = new SpaceMouseController(opened, viewPanel); + viewPanel.addFrameListener(controller); + failureReported = false; + System.out.println("spacemouse: SpaceNavigator detected — " + + "cap moves the camera, left button brakes"); + } catch (final Exception e) { + device = null; + if (!failureReported) { + failureReported = true; + System.err.println("spacemouse unavailable: " + + e.getMessage()); + } + } + } + + private void disconnect() { + if (controller != null) { + viewPanel.removeFrameListener(controller); + controller = null; + } + if (device != null) { + device.stop(); + device = null; + } + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceNavigatorHid.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceNavigatorHid.java new file mode 100644 index 0000000..9976b84 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceNavigatorHid.java @@ -0,0 +1,186 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.spacemouse; + +import com.sun.jna.Library; +import com.sun.jna.Memory; +import com.sun.jna.Native; + +import java.io.File; + +/** + * HID transport for the 3Dconnexion SpaceNavigator 6DOF mouse + * (USB 046D:C626), read via the Linux hidraw interface using JNA — + * same approach as the RayNeo glasses transport. + * + *

Protocol (from libspnav/spacenavd documentation): the device + * sends 7-byte input reports, ONLY while the cap is deflected or a + * button changes — at rest the pipe is silent (so unlike the XR + * glasses there is no stream watchdog; silence is normal):

+ * + *
    + *
  • report[0] = 1 — translation; int16 LE at [1,2]=X, [3,4]=Y, + * [5,6]=Z; full deflection ≈ ±350
  • + *
  • report[0] = 2 — rotation; int16 LE at [1,2]=RX, [3,4]=RY, + * [5,6]=RZ; full deflection ≈ ±350
  • + *
  • report[0] = 3 — buttons; report[1] bit0 = left, bit1 = right
  • + *
+ * + *

Raw axis sign conventions (SpaceNavigator hardware):

+ *
    + *
  • TX: push cap right → positive
  • + *
  • TY: pull cap toward you → positive (live-verified)
  • + *
  • TZ: pull cap up → positive (live-verified)
  • + *
  • RX: tilt cap top away → positive
  • + *
  • RY: tilt cap top right → positive
  • + *
  • RZ: twist cap clockwise (seen from above) → positive
  • + *
+ * + *

Detection scans /sys/class/hidraw for HID_ID + * 0003:0000046D:0000C626 — the hidraw node changes on replug, so never + * cache the path. Unplug is detected by a read error (read returns + * <0).

+ */ +public final class SpaceNavigatorHid { + + private static final String HID_ID = "0003:0000046D:0000C626"; + + /** Full deflection of any axis, per the HID descriptor. */ + public static final double FULL_DEFLECTION = 350.0; + + /** + * Same JNA calling convention as the proven RayNeo glasses + * transport: static singleton mapping, Memory buffers (a raw + * byte[] mapping correlated with native heap corruption — + * "free(): invalid pointer" — under active report streaming). + */ + private interface CLib extends Library { + CLib INSTANCE = Native.load("c", CLib.class); + + int open(String path, int flags); + + int close(int fd); + + int read(int fd, Memory buffer, int count); + } + + private final File deviceNode; + private final Memory readBuffer = new Memory(64); + + private int fd = -1; + private Thread reader; + private volatile boolean running; + + /** Latest axis state, in raw device units (±350). */ + private volatile int tx, ty, tz, rx, ry, rz; + private volatile int buttons; + /** Set when a read error (typically unplug) killed the reader. */ + private volatile boolean broken; + + private SpaceNavigatorHid(final File deviceNode) { + this.deviceNode = deviceNode; + } + + /** + * Finds and opens the first SpaceNavigator on the system, or null + * when none is plugged in. Throws when the device is present but + * cannot be opened (permissions — see + * /etc/udev/rules.d/99-spacenavigator.rules). + */ + public static SpaceNavigatorHid open() { + final File hidrawDir = new File("/sys/class/hidraw"); + final File[] entries = hidrawDir.listFiles(); + if (entries == null) + return null; + for (final File entry : entries) { + final File uevent = new File(entry, "device/uevent"); + if (!uevent.isFile()) + continue; + try { + final String content = new String( + java.nio.file.Files.readAllBytes(uevent.toPath())); + if (!content.contains(HID_ID)) + continue; + final File node = new File("/dev", entry.getName()); + final SpaceNavigatorHid hid = new SpaceNavigatorHid(node); + hid.openNode(); + return hid; + } catch (final Exception e) { + throw new RuntimeException("SpaceNavigator found at " + + entry.getName() + " but cannot be opened: " + + e.getMessage()); + } + } + return null; + } + + private void openNode() { + fd = CLib.INSTANCE.open(deviceNode.getAbsolutePath(), + 2 /* O_RDWR */); + if (fd < 0) + throw new RuntimeException("open(" + deviceNode + + ") failed: " + Native.getLastError()); + } + + public void start() { + running = true; + reader = new Thread(this::readLoop, "spacenavigator-hid"); + reader.setDaemon(true); + reader.start(); + } + + private void readLoop() { + while (running) { + final int n = CLib.INSTANCE.read(fd, readBuffer, 64); + if (n < 0) { + broken = true; // unplugged + return; + } + if (n < 7) + continue; + final int reportId = readBuffer.getByte(0) & 0xFF; + if (reportId == 1) { + tx = readBuffer.getShort(1); + ty = readBuffer.getShort(3); + tz = readBuffer.getShort(5); + } else if (reportId == 2) { + rx = readBuffer.getShort(1); + ry = readBuffer.getShort(3); + rz = readBuffer.getShort(5); + } else if (reportId == 3) { + buttons = readBuffer.getByte(1) & 0xFF; + } + } + } + + /** True while the device is connected and the reader is alive. */ + public boolean isRunning() { + return running && !broken; + } + + public int getTx() { return tx; } + public int getTy() { return ty; } + public int getTz() { return tz; } + public int getRx() { return rx; } + public int getRy() { return ry; } + public int getRz() { return rz; } + public int getButtons() { return buttons; } + + public void stop() { + running = false; + if (fd >= 0) { + CLib.INSTANCE.close(fd); + fd = -1; + } + if (reader != null) { + try { + reader.join(1000); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + reader = null; + } + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/Character.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/Character.java new file mode 100644 index 0000000..5d86f67 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/Character.java @@ -0,0 +1,29 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.textEditorComponent; + +/** + * A character in a text editor. + */ +public class Character { + + /** + * The character value. + */ + char value; + + /** + * Creates a character with the given value. + * + * @param value the character value + */ + public Character(final char value) { + this.value = value; + } + + boolean hasValue() { + return value != ' '; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/LookAndFeel.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/LookAndFeel.java new file mode 100644 index 0000000..aebf041 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/LookAndFeel.java @@ -0,0 +1,41 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.textEditorComponent; + +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; + +/** + * A look and feel of a text editor. + */ +public class LookAndFeel { + + /** Default foreground (text) color. */ + public Color foreground = new Color(255, 255, 255); + + /** Default background color. */ + public Color background = new Color(20, 20, 20, 255); + + /** Background color for tab stop positions. */ + public Color tabStopBackground = new Color(25, 25, 25, 255); + + /** Cursor foreground color. */ + public Color cursorForeground = new Color(255, 255, 255); + + /** Cursor background color. */ + public Color cursorBackground = new Color(255, 0, 0); + + /** Selection foreground color. */ + public Color selectionForeground = new Color(255, 255, 255); + + /** Selection background color. */ + public Color selectionBackground = new Color(0, 80, 80); + + /** + * Creates a look and feel with default colors. + */ + public LookAndFeel() { + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/Page.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/Page.java new file mode 100644 index 0000000..06e94b8 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/Page.java @@ -0,0 +1,162 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.textEditorComponent; + +import java.util.ArrayList; +import java.util.List; + +/** + * A page in a text editor. + */ +public class Page { + + /** + * The text lines. + */ + public List rows = new ArrayList<>(); + + /** + * Creates a new empty page. + */ + public Page() { + } + + /** + * Ensures that the page has at least the specified number of lines. + * + * @param row the minimum number of lines required + */ + public void ensureMaxTextLine(final int row) { + while (rows.size() <= row) + rows.add(new TextLine()); + } + + /** + * Returns the character at the specified location. + * If the location is out of bounds, returns a space. + * + * @param row the row index + * @param column the column index + * @return the character at the specified location + */ + public char getChar(final int row, final int column) { + if (rows.size() <= row) + return ' '; + return rows.get(row).getCharForLocation(column); + } + + /** + * Returns the specified line. + * + * @param row The line number. + * @return The line. + */ + public TextLine getLine(final int row) { + ensureMaxTextLine(row); + return rows.get(row); + } + + /** + * Returns the length of the specified line. + * + * @param row The line number. + * @return The length of the line. + */ + public int getLineLength(final int row) { + if (rows.size() <= row) + return 0; + return rows.get(row).getLength(); + } + + /** + * Returns the number of lines in the page. + * + * @return The number of lines in the page. + */ + public int getLinesCount() { + pack(); + return rows.size(); + } + + /** + * Returns the text of the page. + * + * @return The text of the page. + */ + public String getText() { + pack(); + + final StringBuilder result = new StringBuilder(); + for (final TextLine textLine : rows) { + if (result.length() > 0) + result.append("\n"); + result.append(textLine.toString()); + } + return result.toString(); + } + + /** + * Inserts a character at the specified position. + * + * @param row the row index + * @param col the column index + * @param value the character to insert + */ + public void insertCharacter(final int row, final int col, final char value) { + getLine(row).insertCharacter(col, value); + } + + /** + * Inserts a line at the specified row. + * + * @param row the row index where to insert + * @param textLine the text line to insert + */ + public void insertLine(final int row, final TextLine textLine) { + rows.add(row, textLine); + } + + /** + * Removes empty lines from the end of the page. + */ + private void pack() { + int newLength = 0; + + for (int i = rows.size() - 1; i >= 0; i--) + if (!rows.get(i).isEmpty()) { + newLength = i + 1; + break; + } + + if (newLength == rows.size()) + return; + + rows = rows.subList(0, newLength); + } + + /** + * Removes the specified character from the page. + * + * @param row The line number. + * @param col The character number. + */ + public void removeCharacter(final int row, final int col) { + if (rows.size() <= row) + return; + getLine(row).removeCharacter(col); + } + + /** + * Removes the specified line from the page. + * + * @param row The line number. + */ + public void removeLine(final int row) { + if (rows.size() <= row) + return; + rows.remove(row); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextEditComponent.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextEditComponent.java new file mode 100755 index 0000000..8e62ff5 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextEditComponent.java @@ -0,0 +1,915 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.textEditorComponent; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.gui.GuiComponent; +import eu.svjatoslav.aukio.e3d.gui.TextPointer; +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; +import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardHelper; +import eu.svjatoslav.aukio.e3d.math.Transform; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas; + +import java.awt.*; +import java.awt.datatransfer.*; +import java.awt.event.KeyEvent; +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +/** + * A full-featured text editor component rendered in 3D space. + * + *

Extends {@link GuiComponent} to integrate keyboard focus management and mouse + * interaction with a multi-line text editing surface. The editor is backed by a + * {@link Page} model containing {@link TextLine} instances and rendered via a + * {@link TextCanvas}.

+ * + *

Supported editing features:

+ *
    + *
  • Cursor navigation with arrow keys, Home, End, Page Up, and Page Down
  • + *
  • Text selection via Shift + arrow keys
  • + *
  • Clipboard operations: Ctrl+C (copy), Ctrl+X (cut), Ctrl+V (paste), Ctrl+A (select all)
  • + *
  • Word-level cursor movement with Ctrl+Left and Ctrl+Right
  • + *
  • Tab indentation and Shift+Tab dedentation for single lines and block selections
  • + *
  • Backspace dedentation of selected blocks (removes 4 spaces of indentation)
  • + *
  • Automatic scrolling when the cursor moves beyond the visible area
  • + *
+ * + *

Usage example:

+ *
{@code
+ * // Create a look and feel (or use defaults)
+ * LookAndFeel lookAndFeel = new LookAndFeel();
+ *
+ * // Create the text editor at a position in 3D space
+ * TextEditComponent editor = new TextEditComponent(
+ *     new Transform(new Point3D(0, 0, 500)),  // position in world
+ *     viewPanel,                                // the active ViewPanel
+ *     new Point2D(800, 600),                    // size in world coordinates
+ *     lookAndFeel
+ * );
+ *
+ * // Set initial content
+ * editor.setText("Hello, World!\nSecond line of text.");
+ *
+ * // Add to the scene
+ * viewPanel.getRootShapeCollection().addShape(editor);
+ * }
+ * + * @see GuiComponent the base class providing keyboard focus and mouse click handling + * @see Page the underlying text model holding all lines + * @see TextCanvas the rendering surface for character-based output + * @see LookAndFeel configurable colors for the editor's visual appearance + * @see TextPointer row/column pointer used for cursor and selection positions + */ +public class TextEditComponent extends GuiComponent implements ClipboardOwner { + + private static final long serialVersionUID = -7118833957783600630L; + + /** + * Text rows that need to be repainted. + */ + private final Set dirtyRows = new HashSet<>(); + + + /** + * The text canvas used to render characters on screen. + */ + private final TextCanvas textCanvas; + + /** + * The number of characters the view is scrolled horizontally. + */ + public int scrolledCharacters = 0; + + /** + * The number of lines the view is scrolled vertically. + */ + public int scrolledLines = 0; + + /** + * Whether the user is currently in selection mode (Shift key held during navigation). + */ + public boolean selecting = false; + + /** + * Selection start and end pointers. + */ + public TextPointer selectionStart = new TextPointer(0, 0); + + /** + * The end position of the text selection. + */ + public TextPointer selectionEnd = new TextPointer(0, 0); + + /** + * The current cursor position in the text (row and column). + */ + public TextPointer cursorLocation = new TextPointer(0, 0); + + /** + * The page model holding all text lines. + */ + Page page = new Page(); + + /** + * The look and feel configuration controlling editor colors. + */ + LookAndFeel lookAndFeel; + + /** + * If true, the page will be repainted on the next update. + */ + boolean repaintPage = false; + + /** + * Creates a new text editor component positioned in 3D space. + * + *

The editor dimensions in rows and columns are computed from the given world-coordinate + * size and the font character dimensions defined in {@link TextCanvas}. A {@link TextCanvas} + * is created internally and added as a child shape.

+ * + * @param transform the position and orientation of the editor in 3D space + * @param viewPanel the view panel this editor belongs to + * @param sizeInWorldCoordinates the editor size in world coordinates (width, height); + * determines the number of visible columns and rows + * @param lookAndFeel the color configuration for the editor's visual appearance + */ + public TextEditComponent(final Transform transform, + final ViewPanel viewPanel, + final Point2D sizeInWorldCoordinates, + LookAndFeel lookAndFeel) { + super(transform, viewPanel, sizeInWorldCoordinates.to3D()); + + this.lookAndFeel = lookAndFeel; + final int columns = (int) (sizeInWorldCoordinates.x / TextCanvas.FONT_CHAR_WIDTH); + final int rows = (int) (sizeInWorldCoordinates.y / TextCanvas.FONT_CHAR_HEIGHT); + + textCanvas = new TextCanvas( + new Transform(), + new TextPointer(rows, columns), + lookAndFeel.foreground, lookAndFeel.background); + + textCanvas.setMouseInteractionController(this); + + repaintPage(); + addShape(textCanvas); + } + + /** + * Ensures the cursor stays within the visible editor area by adjusting + * scroll offsets when the cursor moves beyond the visible boundaries. + * Also clamps the cursor position so that row and column are never negative. + */ + private void checkCursorBoundaries() { + if (cursorLocation.column < 0) + cursorLocation.column = 0; + if (cursorLocation.row < 0) + cursorLocation.row = 0; + + // ensure chat cursor stays within vertical editor boundaries by + // vertical scrolling + if ((cursorLocation.row - scrolledLines) < 0) + scroll(0, cursorLocation.row - scrolledLines); + + if ((((cursorLocation.row - scrolledLines) + 1)) > textCanvas.getSize().row) + scroll(0, + ((((((cursorLocation.row - scrolledLines) + 1) - textCanvas + .getSize().row))))); + + // ensure chat cursor stays within horizontal editor boundaries by + // horizontal scrolling + if ((cursorLocation.column - scrolledCharacters) < 0) + scroll(cursorLocation.column - scrolledCharacters, 0); + + if ((((cursorLocation.column - scrolledCharacters) + 1)) > textCanvas + .getSize().column) + scroll((((((cursorLocation.column - scrolledCharacters) + 1) - textCanvas + .getSize().column))), 0); + } + + /** + * Clears the current text selection by setting the selection end to match + * the selection start, effectively making the selection empty. + * + *

A full page repaint is scheduled to remove the visual selection highlight.

+ */ + public void clearSelection() { + selectionEnd = new TextPointer(selectionStart); + repaintPage = true; + } + + /** + * Copies the currently selected text to the system clipboard. + * + *

If no text is selected (i.e., selection start equals selection end), + * this method does nothing. Multi-line selections are joined with newline + * characters.

+ * + * @see #setClipboardContents(String) + * @see #cutToClipboard() + */ + public void copyToClipboard() { + if (selectionStart.compareTo(selectionEnd) == 0) + return; + // System.out.println("Copy action."); + final StringBuilder msg = new StringBuilder(); + + ensureSelectionOrder(); + + for (int row = selectionStart.row; row <= selectionEnd.row; row++) { + final TextLine textLine = page.getLine(row); + + if (row == selectionStart.row) { + if (row == selectionEnd.row) + msg.append(textLine.getSubString(selectionStart.column, + selectionEnd.column + 1)); + else + msg.append(textLine.getSubString(selectionStart.column, + textLine.getLength())); + } else { + msg.append('\n'); + if (row == selectionEnd.row) + msg.append(textLine + .getSubString(0, selectionEnd.column + 1)); + else + msg.append(textLine.toString()); + } + } + + setClipboardContents(msg.toString()); + } + + /** + * Cuts the currently selected text to the system clipboard. + * + *

This copies the selected text to the clipboard via {@link #copyToClipboard()}, + * then deletes the selection from the page and triggers a full repaint.

+ * + * @see #copyToClipboard() + * @see #deleteSelection() + */ + public void cutToClipboard() { + copyToClipboard(); + deleteSelection(); + repaintPage(); + } + + /** + * Deletes the currently selected text from the page. + * + *

After deletion, the selection is cleared and the cursor is moved to + * the position where the selection started.

+ * + * @see #ensureSelectionOrder() + */ + public void deleteSelection() { + ensureSelectionOrder(); + int ym = 0; + + for (int line = selectionStart.row; line <= selectionEnd.row; line++) { + final TextLine currentLine = page.getLine(line - ym); + + if (line == selectionStart.row) { + if (line == selectionEnd.row) + + currentLine.cutSubString(selectionStart.column, + selectionEnd.column); + else if (selectionStart.column == 0) { + page.removeLine(line - ym); + ym++; + } else + currentLine.cutSubString(selectionStart.column, + currentLine.getLength() + 1); + } else if (line == selectionEnd.row) + currentLine.cutSubString(0, selectionEnd.column); + else { + page.removeLine(line - ym); + ym++; + } + } + + clearSelection(); + cursorLocation = new TextPointer(selectionStart); + } + + /** + * Ensures that {@link #selectionStart} is smaller than + * {@link #selectionEnd}. + * + *

If the start pointer is after the end pointer (e.g., when the user + * selected text backwards), the two pointers are swapped so that + * subsequent operations can iterate from start to end.

+ */ + public void ensureSelectionOrder() { + if (selectionStart.compareTo(selectionEnd) > 0) { + final TextPointer temp = selectionEnd; + selectionEnd = selectionStart; + selectionStart = temp; + } + } + + /** + * Retrieves the current text contents of the system clipboard. + * + * @return the clipboard text content, or an empty string if the clipboard + * is empty or does not contain text + */ + public String getClipboardContents() { + String result = ""; + final Clipboard clipboard = Toolkit.getDefaultToolkit() + .getSystemClipboard(); + // odd: the Object param of getContents is not currently used + final Transferable contents = clipboard.getContents(null); + final boolean hasTransferableText = (contents != null) + && contents.isDataFlavorSupported(DataFlavor.stringFlavor); + if (hasTransferableText) + try { + result = (String) contents + .getTransferData(DataFlavor.stringFlavor); + } catch (final UnsupportedFlavorException | IOException ex) { + // highly unlikely since we are using a standard DataFlavor + System.out.println(ex); + } + // System.out.println(result); + return result; + } + + /** + * Places the given string into the system clipboard so that it can be + * pasted into other applications. + * + * @param contents the text to place on the clipboard + * @see #getClipboardContents() + * @see #copyToClipboard() + */ + public void setClipboardContents(final String contents) { + final StringSelection stringSelection = new StringSelection(contents); + final Clipboard clipboard = Toolkit.getDefaultToolkit() + .getSystemClipboard(); + clipboard.setContents(stringSelection, stringSelection); + } + + /** + * Scrolls to and positions the cursor at the beginning of the specified line. + * + *

The view is scrolled so the target line is visible, the cursor is placed + * at the start of that line (column 0), and a full repaint is triggered.

+ * + * @param Line the zero-based line number to navigate to + */ + public void goToLine(final int Line) { + // markNavigationLocation(Line); + scrolledLines = Line + 1; + cursorLocation.row = Line + 1; + cursorLocation.column = 0; + repaintPage(); + } + + /** + * Inserts the given text string at the current cursor position. + * + *

The text is processed character by character. Special characters are + * handled as editing operations:

+ *
    + *
  • {@code DEL} -- deletes the character at the cursor
  • + *
  • {@code ENTER} -- splits the current line at the cursor
  • + *
  • {@code BACKSPACE} -- deletes the character before the cursor
  • + *
+ *

All other printable characters are inserted at the cursor position, + * advancing the cursor column by one for each character.

+ * + * @param txt the text to insert; {@code null} values are silently ignored + */ + public void insertText(final String txt) { + if (txt == null) + return; + + for (final char c : txt.toCharArray()) { + + if (c == KeyboardHelper.DEL) { + processDel(); + continue; + } + + if (c == KeyboardHelper.ENTER) { + processEnter(); + continue; + } + + if (c == KeyboardHelper.BACKSPACE) { + processBackspace(); + continue; + } + + // type character + if (KeyboardHelper.isText(c)) { + page.insertCharacter(cursorLocation.row, cursorLocation.column, + c); + cursorLocation.column++; + } + } + } + + /** + * Handles a key press event by routing it through the editor's input processing + * pipeline. + * + *

This method delegates to the parent {@link GuiComponent#keyPressed(KeyEvent, ViewPanel)} + * (which handles ESC for focus release), then processes the key event for text editing, + * marks the affected row as dirty, adjusts scroll boundaries, and repaints as needed.

+ * + * @param event the keyboard event + * @param viewPanel the view panel that dispatched this event + * @return always {@code true}, indicating the event was consumed + */ + @Override + public boolean keyPressed(final KeyEvent event, final ViewPanel viewPanel) { + super.keyPressed(event, viewPanel); + + processKeyEvent(event); + + markRowDirty(); + + checkCursorBoundaries(); + + repaintWhatNeeded(); + return true; + } + + /** + * Called when this editor loses ownership of the system clipboard. + * + *

This is an empty implementation of the {@link ClipboardOwner} interface; + * no action is taken when clipboard ownership is lost.

+ * + * @param aClipboard the clipboard that this editor previously owned + * @param aContents the contents that were previously placed on the clipboard + */ + @Override + public void lostOwnership(final Clipboard aClipboard, + final Transferable aContents) { + // do nothing + } + + /** + * Marks the current cursor row as dirty, scheduling it for repaint on the + * next rendering cycle. + */ + public void markRowDirty() { + dirtyRows.add(cursorLocation.row); + } + + /** + * Pastes text from the system clipboard at the current cursor position. + * + * @see #getClipboardContents() + * @see #insertText(String) + */ + public void pasteFromClipboard() { + insertText(getClipboardContents()); + } + + /** + * Processes the backspace key action. + * + *

If there is no active selection, deletes the character before the cursor. + * If the cursor is at the beginning of a line, merges the current line with the + * previous one. If there is an active selection, dedents the selected lines by + * removing up to 4 leading spaces (block dedentation).

+ */ + private void processBackspace() { + if (selectionStart.compareTo(selectionEnd) == 0) { + // erase single character + if (cursorLocation.column > 0) { + cursorLocation.column--; + page.removeCharacter(cursorLocation.row, cursorLocation.column); + // System.out.println(lines.get(currentCursor.line).toString()); + } else if (cursorLocation.row > 0) { + cursorLocation.row--; + final int currentLineLength = page + .getLineLength(cursorLocation.row); + cursorLocation.column = currentLineLength; + page.getLine(cursorLocation.row) + .insertTextLine(currentLineLength, + page.getLine(cursorLocation.row + 1)); + page.removeLine(cursorLocation.row + 1); + repaintPage = true; + } + } else { + // dedent multiple lines + ensureSelectionOrder(); + // scan if enough space exists + for (int y = selectionStart.row; y < selectionEnd.row; y++) + if (page.getLine(y).getIndent() < 4) + return; + + for (int y = selectionStart.row; y < selectionEnd.row; y++) + page.getLine(y).cutFromBeginning(4); + + repaintPage = true; + } + } + + /** + * Processes keyboard shortcuts involving the Ctrl modifier key. + * + *

Supported combinations:

+ *
    + *
  • Ctrl+A -- select all text
  • + *
  • Ctrl+X -- cut selected text to clipboard
  • + *
  • Ctrl+C -- copy selected text to clipboard
  • + *
  • Ctrl+V -- paste from clipboard
  • + *
  • Ctrl+Right -- skip to the beginning of the next word
  • + *
  • Ctrl+Left -- skip to the beginning of the previous word
  • + *
+ * + * @param keyCode the key code of the pressed key (combined with Ctrl) + */ + private void processCtrlCombinations(final int keyCode) { + + if ((char) keyCode == 'A') { // CTRL + A -- select all + final int lastLineIndex = page.getLinesCount() - 1; + selectionStart = new TextPointer(0, 0); + selectionEnd = new TextPointer(lastLineIndex, + page.getLineLength(lastLineIndex)); + repaintPage(); + } + + // CTRL + X -- cut + if ((char) keyCode == 'X') + cutToClipboard(); + + // CTRL + C -- copy + if ((char) keyCode == 'C') + copyToClipboard(); + + // CTRL + V -- paste + if ((char) keyCode == 'V') + pasteFromClipboard(); + + if (keyCode == 39) { // RIGHT + // skip to the beginning of the next word + + for (int x = cursorLocation.column; x < (page + .getLineLength(cursorLocation.row) - 1); x++) + if ((page.getChar(cursorLocation.row, x) == ' ') + && (page.getChar(cursorLocation.row, x + 1) != ' ')) { + // beginning of the next word is found + cursorLocation.column = x + 1; + return; + } + + cursorLocation.column = page.getLineLength(cursorLocation.row); + return; + } + + if (keyCode == 37) { // Left + + // skip to the beginning of the previous word + for (int x = cursorLocation.column - 2; x >= 0; x--) + if ((page.getChar(cursorLocation.row, x) == ' ') + & (page.getChar(cursorLocation.row, x + 1) != ' ')) { + cursorLocation.column = x + 1; + return; + } + + cursorLocation.column = 0; + } + } + + /** + * Processes the Delete key action. + * + *

If there is no active selection, deletes the character at the cursor position. + * If the cursor is at the end of the line, the next line is merged into the current one. + * If there is an active selection, the entire selection is deleted.

+ */ + public void processDel() { + if (selectionStart.compareTo(selectionEnd) == 0) { + // is there still some text right to the cursor ? + if (cursorLocation.column < page.getLineLength(cursorLocation.row)) + page.removeCharacter(cursorLocation.row, cursorLocation.column); + else { + page.getLine(cursorLocation.row).insertTextLine( + cursorLocation.column, + page.getLine(cursorLocation.row + 1)); + page.removeLine(cursorLocation.row + 1); + repaintPage = true; + } + } else { + deleteSelection(); + repaintPage = true; + } + } + + /** + * Processes the Enter key action by splitting the current line at the cursor position. + * + *

Everything to the right of the cursor is moved to a new line inserted + * below. The cursor moves to the beginning of the new line.

+ */ + private void processEnter() { + final TextLine currentLine = page.getLine(cursorLocation.row); + // move everything right to the cursor into new line + final TextLine newLine = currentLine.getSubLine(cursorLocation.column, + currentLine.getLength()); + page.insertLine(cursorLocation.row + 1, newLine); + + // trim existing line + page.getLine(cursorLocation.row).cutUntilEnd(cursorLocation.column); + repaintPage = true; + + cursorLocation.row++; + cursorLocation.column = 0; + } + + /** + * Routes a keyboard event to the appropriate handler based on modifier keys + * and key codes. + * + *

Handles Ctrl combinations, Tab/Shift+Tab, text input, Shift-based selection, + * and cursor navigation keys (Home, End, arrows, Page Up/Down). Alt key events + * are ignored.

+ * + * @param event the keyboard event to process + */ + private void processKeyEvent(final KeyEvent event) { + final int modifiers = event.getModifiersEx(); + final int keyCode = event.getKeyCode(); + final char keyChar = event.getKeyChar(); + + // System.out.println("Keycode:" + keyCode s+ ", keychar:" + keyChar); + + if (KeyboardHelper.isAltPressed(modifiers)) + return; + + if (KeyboardHelper.isCtrlPressed(modifiers)) { + processCtrlCombinations(keyCode); + return; + } + + if (keyCode == KeyboardHelper.TAB) { + processTab(modifiers); + return; + } + + clearSelection(); + + if (KeyboardHelper.isText(keyCode)) { + insertText(String.valueOf(keyChar)); + return; + } + + if (KeyboardHelper.isShiftPressed(modifiers)) { + if (!selecting) + attemptSelectionStart:{ + + if (keyChar == 65535) + if (keyCode == 16) + break attemptSelectionStart; + if (((keyChar >= 32) & (keyChar <= 128)) | (keyChar == 10) + | (keyChar == 8) | (keyChar == 9)) + break attemptSelectionStart; + + selectionStart = new TextPointer(cursorLocation); + selectionEnd = selectionStart; + selecting = true; + repaintPage(); + } + } else + selecting = false; + + if (keyCode == KeyboardHelper.HOME) { + cursorLocation.column = 0; + return; + } + if (keyCode == KeyboardHelper.END) { + cursorLocation.column = page.getLineLength(cursorLocation.row); + return; + } + + // process cursor keys + if (keyCode == KeyboardHelper.DOWN) { + markRowDirty(); + cursorLocation.row++; + return; + } + + if (keyCode == KeyboardHelper.UP) { + markRowDirty(); + cursorLocation.row--; + return; + } + + if (keyCode == KeyboardHelper.RIGHT) { + cursorLocation.column++; + return; + } + + if (keyCode == KeyboardHelper.LEFT) { + cursorLocation.column--; + return; + } + + if (keyCode == KeyboardHelper.PGDOWN) { + cursorLocation.row += textCanvas.getSize().row; + repaintPage(); + return; + } + + if (keyCode == KeyboardHelper.PGUP) { + cursorLocation.row -= textCanvas.getSize().row; + repaintPage = true; + } + + } + + /** + * Processes the Tab key action for indentation and dedentation. + * + *

Behavior depends on modifiers and selection state:

+ *
    + *
  • Shift+Tab with selection: dedents all selected lines by + * removing up to 4 leading spaces, if all lines have sufficient indentation
  • + *
  • Shift+Tab without selection: dedents the current line by + * removing 4 leading spaces and moving the cursor back
  • + *
  • Tab with selection: indents all selected lines by adding + * 4 leading spaces
  • + *
+ * + * @param modifiers the keyboard modifier flags from the key event + */ + private void processTab(final int modifiers) { + if (KeyboardHelper.isShiftPressed(modifiers)) { + if (selectionStart.compareTo(selectionEnd) != 0) { + // dedent multiple lines + ensureSelectionOrder(); + + identSelection: + { + // check that indentation is possible + for (int y = selectionStart.row; y < selectionEnd.row; y++) { + final TextLine textLine = page.getLine(y); + + if (!textLine.isEmpty()) + if (textLine.getIndent() < 4) + break identSelection; + } + + for (int y = selectionStart.row; y < selectionEnd.row; y++) + page.getLine(y).cutFromBeginning(4); + } + } else { + // dedent current line + final TextLine textLine = page.getLine(cursorLocation.row); + + if (cursorLocation.column >= 4) + if (textLine.isEmpty()) + cursorLocation.column -= 4; + else if (textLine.getIndent() >= 4) { + cursorLocation.column -= 4; + textLine.cutFromBeginning(4); + } + + } + + repaintPage(); + + } else if (selectionStart.compareTo(selectionEnd) != 0) { + // indent multiple lines + ensureSelectionOrder(); + for (int y = selectionStart.row; y < selectionEnd.row; y++) + page.getLine(y).addIndent(4); + + repaintPage(); + } + } + + /** + * Repaints the entire visible page area onto the text canvas. + * + *

Iterates over every visible cell (row and column), applying the appropriate + * foreground and background colors based on whether the cell is the cursor position, + * part of a selection, or a tab stop margin. Characters are read from the underlying + * {@link Page} model with scroll offsets applied.

+ */ + public void repaintPage() { + + final int columnCount = textCanvas.getSize().column + 2; + final int rowCount = textCanvas.getSize().row + 2; + + for (int row = 0; row < rowCount; row++) + for (int column = 0; column < columnCount; column++) { + final boolean isTabMargin = ((column + scrolledCharacters) % 4) == 0; + + if ((column == (cursorLocation.column - scrolledCharacters)) + & (row == (cursorLocation.row - scrolledLines))) { + // cursor + textCanvas.setBackgroundColor(lookAndFeel.cursorBackground); + textCanvas.setForegroundColor(lookAndFeel.cursorForeground); + } else if (new TextPointer(row + scrolledLines, column).isBetween( + selectionStart, selectionEnd)) { + // selected text + textCanvas.setBackgroundColor(lookAndFeel.selectionBackground); + textCanvas.setForegroundColor(lookAndFeel.selectionForeground); + } else { + // normal text + textCanvas.setBackgroundColor(lookAndFeel.background); + textCanvas.setForegroundColor(lookAndFeel.foreground); + + if (isTabMargin) + textCanvas + .setBackgroundColor(lookAndFeel.tabStopBackground); + + } + + final char charUnderCursor = page.getChar(row + scrolledLines, + column + scrolledCharacters); + + textCanvas.putChar(row, column, charUnderCursor); + } + + } + + /** + * Repaints a single row of the editor. + * + *

Note: the current implementation delegates to + * {@link #repaintPage()} and repaints the entire page. This is a candidate + * for optimization.

+ * + * @param rowNumber the zero-based row index to repaint + */ + public void repaintRow(final int rowNumber) { + // TODO: Optimize this. No need to repaint entire page. + repaintPage(); + } + + /** + * Repaints only the portions of the editor that have been marked as dirty. + * + *

If {@link #repaintPage} is set, the entire page is repainted and all + * dirty row tracking is cleared. Otherwise, only the individually dirty rows + * are repainted.

+ */ + private void repaintWhatNeeded() { + if (repaintPage) { + dirtyRows.clear(); + repaintPage(); + return; + } + + dirtyRows.forEach(this::repaintRow); + dirtyRows.clear(); + } + + /** + * Scrolls the visible editor area by the specified number of characters and lines. + * + *

Scroll offsets are clamped so they never go below zero. A full page + * repaint is scheduled after scrolling.

+ * + * @param charactersToScroll the number of characters to scroll horizontally + * (positive = right, negative = left) + * @param linesToScroll the number of lines to scroll vertically + * (positive = down, negative = up) + */ + public void scroll(final int charactersToScroll, final int linesToScroll) { + scrolledLines += linesToScroll; + scrolledCharacters += charactersToScroll; + + if (scrolledLines < 0) + scrolledLines = 0; + + if (scrolledCharacters < 0) + scrolledCharacters = 0; + + repaintPage = true; + } + + /** + * Replaces the entire editor content with the given text. + * + *

Resets the cursor to position (0, 0), clears all scroll offsets and + * selections, creates a fresh {@link Page}, inserts the text, and triggers + * a full repaint.

+ * + * @param text the new text content for the editor; may contain newline + * characters to create multiple lines + */ + public void setText(final String text) { + // System.out.println("Set text:" + text); + cursorLocation = new TextPointer(0, 0); + scrolledCharacters = 0; + scrolledLines = 0; + selectionStart = new TextPointer(0, 0); + selectionEnd = new TextPointer(0, 0); + page = new Page(); + insertText(text); + repaintPage(); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextLine.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextLine.java new file mode 100755 index 0000000..671fa0f --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextLine.java @@ -0,0 +1,410 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.gui.textEditorComponent; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a single line of text in the text editor. + * + *

Internally stores a mutable list of {@link Character} objects, one per character in + * the line. Provides operations for inserting, cutting, and copying substrings, as well + * as indentation manipulation (adding or removing leading spaces).

+ * + *

Lines automatically trim trailing whitespace via the internal {@code pack()} method, + * which is invoked after most mutating operations. This ensures that lines never store + * unnecessary trailing space characters.

+ * + * @see Character the wrapper for individual character values in a line + * @see Page the container that holds multiple {@code TextLine} instances + * @see TextEditComponent the text editor component that uses lines for editing + */ +public class TextLine { + + private List chars = new ArrayList<>(); + + /** + * Creates an empty text line with no characters. + */ + public TextLine() { + } + + /** + * Creates a text line from an existing list of {@link Character} objects. + * + *

Trailing whitespace is automatically trimmed via {@code pack()}.

+ * + * @param value the list of characters to initialize this line with + */ + public TextLine(final List value) { + chars = value; + pack(); + } + + /** + * Creates a text line initialized with the given string. + * + *

Each character in the string is converted to a {@link Character} object. + * Trailing whitespace is automatically trimmed.

+ * + * @param value the string to initialize this line with + */ + public TextLine(final String value) { + setValue(value); + } + + /** + * Adds indentation (leading spaces) to the beginning of this line. + * + *

If the line is empty, no indentation is added. Otherwise, the specified + * number of space characters are prepended to the line.

+ * + * @param amount the number of space characters to prepend + */ + public void addIndent(final int amount) { + if (isEmpty()) + return; + + for (int i = 0; i < amount; i++) + chars.add(0, new Character(' ')); + } + + /** + * Removes characters from the specified range and returns them as a string. + * + *

This is a destructive operation: the characters in the range + * [{@code from}, {@code until}) are removed from this line. If the line is + * shorter than {@code until}, it is padded with spaces before extraction. + * Trailing whitespace is trimmed after removal.

+ * + * @param from the start index (inclusive) of the range to extract + * @param until the end index (exclusive) of the range to extract + * @return the extracted characters as a string + */ + public String copySubString(final int from, final int until) { + final StringBuilder result = new StringBuilder(); + + ensureLength(until); + + for (int i = from; i < until; i++) + result.append(chars.remove(from).value); + + pack(); + return result.toString(); + } + + + /** + * Removes the specified number of characters from the beginning of this line. + * + *

If {@code charactersToCut} exceeds the line length, the entire line is cleared. + * If {@code charactersToCut} is zero, no changes are made.

+ * + * @param charactersToCut the number of leading characters to remove + */ + public void cutFromBeginning(int charactersToCut) { + + if (charactersToCut > chars.size()) + charactersToCut = chars.size(); + + if (charactersToCut == 0) + return; + + chars = chars.subList(charactersToCut, chars.size()); + } + + /** + * Extracts a substring from this line, removing those characters and returning them. + * + *

Characters in the range [{@code from}, {@code until}) are removed from this + * line and returned as a string. Characters outside the range are retained. If the + * line is shorter than {@code until}, it is padded with spaces before extraction. + * Trailing whitespace is trimmed after the cut.

+ * + * @param from the start index (inclusive) of the range to cut + * @param until the end index (exclusive) of the range to cut + * @return the cut characters as a string + */ + public String cutSubString(final int from, final int until) { + final StringBuilder result = new StringBuilder(); + + final List reminder = new ArrayList<>(); + + ensureLength(until); + + for (int i = 0; i < chars.size(); i++) + if ((i >= from) && (i < until)) + result.append(chars.get(i).value); + else + reminder.add(chars.get(i)); + + chars = reminder; + + pack(); + return result.toString(); + } + + /** + * Truncates this line at the specified column, discarding all characters from + * that position to the end. + * + *

If {@code col} is greater than or equal to the current line length, + * no changes are made.

+ * + * @param col the column index at which to truncate (exclusive; characters at + * indices 0 through {@code col - 1} are kept) + */ + public void cutUntilEnd(final int col) { + if (col >= chars.size()) + return; + + chars = chars.subList(0, col); + } + + /** + * Ensures the internal character list is at least the given length, + * padding with space characters as needed. + */ + private void ensureLength(final int length) { + while (chars.size() < length) + chars.add(new Character(' ')); + } + + /** + * Returns the character at the specified column position. + * + *

If the column is beyond the end of this line, a space character is returned.

+ * + * @param col the zero-based column index + * @return the character at the given column, or {@code ' '} if out of bounds + */ + public char getCharForLocation(final int col) { + + if (col >= chars.size()) + return ' '; + + return chars.get(col).value; + } + + /** + * Returns the internal list of {@link Character} objects backing this line. + * + *

Note: the returned list is the live internal list. Modifications + * to the returned list will directly affect this line.

+ * + * @return the mutable list of characters in this line + */ + public List getChars() { + return chars; + } + + /** + * Returns the indentation level of this line, measured as the number of + * leading space characters before the first non-space character. + * + *

If the line is empty, returns {@code 0}.

+ * + * @return the number of leading space characters + * @throws RuntimeException if the line is non-empty but contains only spaces + * (should not occur due to trailing whitespace trimming by {@code pack()}) + */ + public int getIndent() { + if (isEmpty()) + return 0; + + for (int i = 0; i < chars.size(); i++) + if (chars.get(i).hasValue()) + return i; + + throw new RuntimeException("This code shall never execute"); + } + + /** + * Returns the length of this line (number of characters, excluding trimmed + * trailing whitespace). + * + * @return the number of characters in this line + */ + public int getLength() { + return chars.size(); + } + + /** + * Returns a new {@code TextLine} containing the characters from this line + * in the range [{@code from}, {@code until}). + * + *

If {@code until} exceeds the line length, only the available characters + * are included. The returned line is an independent copy.

+ * + * @param from the start index (inclusive) + * @param until the end index (exclusive) + * @return a new {@code TextLine} with the specified sub-range of characters + */ + public TextLine getSubLine(final int from, final int until) { + final List result = new ArrayList<>(); + + for (int i = from; i < until; i++) { + if (i >= chars.size()) + break; + result.add(chars.get(i)); + } + + return new TextLine(result); + } + + /** + * Returns a substring of this line from column {@code from} (inclusive) to + * column {@code until} (exclusive). + * + *

If the requested range extends beyond the line length, space characters + * are used for positions past the end of the line.

+ * + * @param from the start column (inclusive) + * @param until the end column (exclusive) + * @return the substring in the specified range + */ + public String getSubString(final int from, final int until) { + final StringBuilder result = new StringBuilder(); + + for (int i = from; i < until; i++) + result.append(getCharForLocation(i)); + + return result.toString(); + } + + /** + * Inserts a single character at the specified column position. + * + *

If the column is beyond the current line length, the line is padded + * with spaces up to that position. Trailing whitespace is trimmed after + * insertion.

+ * + * @param col the zero-based column at which to insert + * @param value the character to insert + */ + public void insertCharacter(final int col, final char value) { + ensureLength(col); + chars.add(col, new Character(value)); + pack(); + } + + /** + * Inserts a string at the specified column position. + * + *

Each character in the string is inserted sequentially starting at + * {@code col}. If the column is beyond the current line length, the line + * is padded with spaces. Trailing whitespace is trimmed after insertion.

+ * + * @param col the zero-based column at which to start inserting + * @param value the string to insert + */ + public void insertString(final int col, final String value) { + ensureLength(col); + int i = 0; + for (final char c : value.toCharArray()) { + chars.add(col + i, new Character(c)); + i++; + } + pack(); + } + + /** + * Inserts all characters from another {@code TextLine} at the specified column. + * + *

If the column is beyond the current line length, the line is padded with + * spaces. Trailing whitespace is trimmed after insertion.

+ * + * @param col the zero-based column at which to start inserting + * @param textLine the text line whose characters will be inserted + */ + public void insertTextLine(final int col, final TextLine textLine) { + ensureLength(col); + int i = 0; + for (final Character c : textLine.getChars()) { + chars.add(col + i, c); + i++; + } + pack(); + } + + /** + * Returns whether this line contains no characters. + * + *

Because trailing whitespace is trimmed, an empty line means there are + * no visible characters on this line.

+ * + * @return {@code true} if the line has no characters, {@code false} otherwise + */ + public boolean isEmpty() { + return chars.isEmpty(); + } + + /** + * Trims trailing whitespace from this line by removing trailing space + * characters that have no visible content. + */ + private void pack() { + int newLength = 0; + + for (int i = chars.size() - 1; i >= 0; i--) + if (chars.get(i).hasValue()) { + newLength = i + 1; + break; + } + + if (newLength == chars.size()) + return; + + chars = chars.subList(0, newLength); + } + + /** + * Removes the character at the specified column position. + * + *

If the column is beyond the end of the line, no changes are made.

+ * + * @param col the zero-based column of the character to remove + */ + public void removeCharacter(final int col) { + if (col >= chars.size()) + return; + + chars.remove(col); + } + + /** + * Replaces the entire contents of this line with the given string. + * + *

The existing characters are cleared, and each character from the string + * is added as a new {@link Character} object. Trailing whitespace is trimmed.

+ * + * @param string the new text content for this line + */ + public void setValue(final String string) { + chars.clear(); + for (final char c : string.toCharArray()) + chars.add(new Character(c)); + + pack(); + } + + /** + * Returns the string representation of this line by concatenating + * all character values. + * + * @return the text content of this line as a {@code String} + */ + @Override + public String toString() { + final StringBuilder buffer = new StringBuilder(); + + for (final Character character : chars) + buffer.append(character.value); + + return buffer.toString(); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/package-info.java new file mode 100644 index 0000000..53ea2a1 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/package-info.java @@ -0,0 +1,6 @@ +/** + * Provides a simple text editor component rendered in 3D space. + * + * @see eu.svjatoslav.aukio.e3d.gui.textEditorComponent.TextEditComponent + */ +package eu.svjatoslav.aukio.e3d.gui.textEditorComponent; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/headless/GoldenImage.java b/src/main/java/eu/svjatoslav/aukio/e3d/headless/GoldenImage.java new file mode 100644 index 0000000..8bbd7e5 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/headless/GoldenImage.java @@ -0,0 +1,168 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.headless; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.Locale; + +/** + * Golden-image comparison: render a scene, compare against a committed + * reference PNG, fail when the picture drifts. + * + *

A pixel counts as different when any RGB channel differs by more than + * a per-channel tolerance; a comparison fails when the fraction of + * differing pixels exceeds a threshold. Both are parameters — shading and + * antialiasing make exact matches fragile, but "the floor lost 30% of its + * pixels" must not pass.

+ * + *

Example:

+ *
{@code
+ * BufferedImage actual = Snapshot.render(scene, lighting, pose, 640, 480);
+ * GoldenImage.Result r = GoldenImage.compare(actual, new File("goldens/house.png"), 8, 0.01);
+ * if (!r.passed) {
+ *     GoldenImage.saveDiff(actual, new File("goldens/house.png"), "/tmp/diff.png");
+ *     throw new AssertionError(r.toString());
+ * }
+ * }
+ * + *

Command line (for scripts):

+ *
+ * java eu.svjatoslav.aukio.e3d.headless.GoldenImage actual.png golden.png [tolerance] [maxDiffFraction]
+ * exit code 0 = match, 1 = differ, 2 = usage/io error
+ * 
+ * + * @see Snapshot + */ +public final class GoldenImage { + + private GoldenImage() { + // utility class + } + + /** Outcome of one comparison. */ + public static final class Result { + /** True when the images match within tolerance. */ + public final boolean passed; + /** Fraction of pixels that differ (0..1). */ + public final double diffFraction; + /** Absolute count of differing pixels. */ + public final int diffPixels; + /** Total pixels compared. */ + public final int totalPixels; + + Result(final boolean passed, final double diffFraction, + final int diffPixels, final int totalPixels) { + this.passed = passed; + this.diffFraction = diffFraction; + this.diffPixels = diffPixels; + this.totalPixels = totalPixels; + } + + @Override + public String toString() { + return String.format(Locale.ROOT, "%s: %d/%d pixels differ (%.4f)", + passed ? "PASS" : "FAIL", diffPixels, totalPixels, diffFraction); + } + } + + /** + * Compares an image against a golden PNG file. + * + * @param actual the rendered image + * @param goldenFile the reference PNG + * @param channelTolerance per-channel (R/G/B) tolerance, 0 = exact + * @param maxDiffFraction maximum allowed fraction of differing pixels (0..1) + * @return the comparison result + * @throws IOException on read failure or size mismatch + */ + public static Result compare(final BufferedImage actual, final File goldenFile, + final int channelTolerance, final double maxDiffFraction) + throws IOException { + final BufferedImage golden = ImageIO.read(goldenFile); + if (golden == null) + throw new IOException("cannot read golden image: " + goldenFile); + if (golden.getWidth() != actual.getWidth() || golden.getHeight() != actual.getHeight()) + throw new IOException("size mismatch: actual " + actual.getWidth() + "x" + actual.getHeight() + + " vs golden " + golden.getWidth() + "x" + golden.getHeight()); + + int diff = 0; + final int width = actual.getWidth(); + final int height = actual.getHeight(); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + if (differs(actual.getRGB(x, y), golden.getRGB(x, y), channelTolerance)) + diff++; + } + } + final int total = width * height; + final double fraction = (double) diff / total; + return new Result(fraction <= maxDiffFraction, fraction, diff, total); + } + + /** + * Writes a visual diff image: matching pixels dimmed, differing pixels + * highlighted red. Handy for inspecting a failed comparison. + * + * @param actual the rendered image + * @param goldenFile the reference PNG + * @param outPath where to write the diff PNG + * @throws IOException on read/write failure or size mismatch + */ + public static void saveDiff(final BufferedImage actual, final File goldenFile, + final String outPath) throws IOException { + final BufferedImage golden = ImageIO.read(goldenFile); + final BufferedImage diff = new BufferedImage(actual.getWidth(), actual.getHeight(), + BufferedImage.TYPE_INT_RGB); + for (int y = 0; y < actual.getHeight(); y++) { + for (int x = 0; x < actual.getWidth(); x++) { + final int a = actual.getRGB(x, y); + if (differs(a, golden.getRGB(x, y), 0)) { + diff.setRGB(x, y, 0xFF0000); + } else { + // dimmed original: quarter brightness + diff.setRGB(x, y, ((a >> 2) & 0x3F3F3F)); + } + } + } + ImageIO.write(diff, "png", new File(outPath)); + } + + /** True when any channel of the two RGB values differs by more than tolerance. */ + private static boolean differs(final int rgb1, final int rgb2, final int tolerance) { + for (int shift = 16; shift >= 0; shift -= 8) { + final int c1 = (rgb1 >> shift) & 0xFF; + final int c2 = (rgb2 >> shift) & 0xFF; + if (Math.abs(c1 - c2) > tolerance) + return true; + } + return false; + } + + /** + * CLI: compares two PNGs. Exit 0 = match, 1 = differ, 2 = error. + * + * @param args actual.png golden.png [channelTolerance] [maxDiffFraction] + */ + public static void main(final String[] args) { + if (args.length < 2) { + System.err.println("usage: GoldenImage actual.png golden.png [channelTolerance] [maxDiffFraction]"); + System.exit(2); + } + try { + final int tolerance = args.length > 2 ? Integer.parseInt(args[2]) : 0; + final double maxFraction = args.length > 3 ? Double.parseDouble(args[3]) : 0.0; + final BufferedImage actual = ImageIO.read(new File(args[0])); + final Result result = compare(actual, new File(args[1]), tolerance, maxFraction); + System.out.println(result); + System.exit(result.passed ? 0 : 1); + } catch (final Exception e) { + System.err.println("error: " + e.getMessage()); + System.exit(2); + } + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/headless/PixelAssertions.java b/src/main/java/eu/svjatoslav/aukio/e3d/headless/PixelAssertions.java new file mode 100644 index 0000000..21286d1 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/headless/PixelAssertions.java @@ -0,0 +1,146 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.headless; + +import java.awt.image.BufferedImage; + +/** + * Pixel-level assertions for headless render verification. + * + *

The recurring question in rasterizer debugging is "did this region of + * the screen actually get painted?" These helpers answer it against a + * {@link BufferedImage} produced by {@link Snapshot}, treating one chosen + * background color as "unpainted". All methods are static and return data + * rather than throwing, so callers decide how to report failures.

+ * + *

Example — assert the floor rendered (no holes from clipping):

+ *
{@code
+ * BufferedImage image = Snapshot.render(scene, lighting, pose, 640, 480);
+ * double holes = PixelAssertions.unpaintedFraction(image, 0x00000000,
+ *         0.15, 0.45, 0.85, 1.0);  // lower-center band
+ * if (holes > 0.05)
+ *     throw new AssertionError("floor has holes: " + holes);
+ * }
+ * + * @see Snapshot + * @see GoldenImage + */ +public final class PixelAssertions { + + private PixelAssertions() { + // utility class + } + + /** + * Fraction of pixels in the whole image that still equal the background + * color (i.e. nothing was painted there). + * + * @param image the rendered image + * @param backgroundRgb the background color (RGB, alpha ignored) + * @return fraction in [0, 1] + */ + public static double unpaintedFraction(final BufferedImage image, final int backgroundRgb) { + return unpaintedFraction(image, backgroundRgb, 0, 0, 1, 1); + } + + /** + * Fraction of pixels inside a relative rectangle that still equal the + * background color. + * + * @param image the rendered image + * @param backgroundRgb the background color (RGB, alpha ignored) + * @param x0 left edge, fraction of width (0..1) + * @param y0 top edge, fraction of height (0..1) + * @param x1 right edge, fraction of width (0..1) + * @param y1 bottom edge, fraction of height (0..1) + * @return fraction in [0, 1] + */ + public static double unpaintedFraction(final BufferedImage image, final int backgroundRgb, + final double x0, final double y0, + final double x1, final double y1) { + final int width = image.getWidth(); + final int height = image.getHeight(); + final int bg = backgroundRgb & 0xFFFFFF; + + int total = 0; + int unpainted = 0; + for (int y = (int) (height * y0); y < (int) (height * y1); y++) { + for (int x = (int) (width * x0); x < (int) (width * x1); x++) { + total++; + if ((image.getRGB(x, y) & 0xFFFFFF) == bg) + unpainted++; + } + } + return total == 0 ? 0 : (double) unpainted / total; + } + + /** + * Counts pixels in the whole image that differ from the background color. + * + * @param image the rendered image + * @param backgroundRgb the background color (RGB, alpha ignored) + * @return number of painted pixels + */ + public static long countPainted(final BufferedImage image, final int backgroundRgb) { + final int bg = backgroundRgb & 0xFFFFFF; + long painted = 0; + for (int y = 0; y < image.getHeight(); y++) + for (int x = 0; x < image.getWidth(); x++) + if ((image.getRGB(x, y) & 0xFFFFFF) != bg) + painted++; + return painted; + } + + /** + * Counts pixels equal (in RGB) to the given color. Useful with flat + * test colors: "how much of the red triangle made it to the screen?" + * + * @param image the rendered image + * @param rgb the color to count (alpha ignored) + * @return number of matching pixels + */ + public static long countColor(final BufferedImage image, final int rgb) { + final int wanted = rgb & 0xFFFFFF; + long count = 0; + for (int y = 0; y < image.getHeight(); y++) + for (int x = 0; x < image.getWidth(); x++) + if ((image.getRGB(x, y) & 0xFFFFFF) == wanted) + count++; + return count; + } + + /** + * Dumps a grid of pixel colors around a point as text, for eyeballing + * gradients/edges in a failing render. Samples every {@code stride} + * pixels, formatted as RRGGBB hex, one row per line. + * + * @param image the rendered image + * @param cx center X in pixels + * @param cy center Y in pixels + * @param radius grid extends this many samples in each direction + * @param stride pixels between samples + * @return multi-line hex grid string + */ + public static String dumpPixelGrid(final BufferedImage image, + final int cx, final int cy, + final int radius, final int stride) { + final StringBuilder sb = new StringBuilder(); + for (int dy = -radius; dy <= radius; dy++) { + for (int dx = -radius; dx <= radius; dx++) { + final int x = cx + dx * stride; + final int y = cy + dy * stride; + if (x < 0 || y < 0 || x >= image.getWidth() || y >= image.getHeight()) { + sb.append(" ---- "); + } else { + sb.append(String.format("%06X", image.getRGB(x, y) & 0xFFFFFF)); + } + if (dx < radius) + sb.append(' '); + } + sb.append('\n'); + } + return sb.toString(); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/headless/SceneDump.java b/src/main/java/eu/svjatoslav/aukio/e3d/headless/SceneDump.java new file mode 100644 index 0000000..3d32901 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/headless/SceneDump.java @@ -0,0 +1,103 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.headless; + +import eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination; +import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightSource; +import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection; +import eu.svjatoslav.aukio.e3d.gui.Camera; + +import java.util.Locale; + +/** + * Text dump of everything that determines what a frame looks like: shape + * counts, lights, camera pose, GI status. One call, one string — paste it + * into a bug report and the scene is reproducible. + * + *

Example:

+ *
{@code
+ * System.out.println(SceneDump.dump(scene, lighting, camera, gi));
+ * }
+ * + *

Sample output:

+ *
+ * == SceneDump ==
+ * shapes: 214 top-level, 1892 queued for rendering
+ * lights: 4 (ambient #181818)
+ *   [0] pos=(-800.0, -240.0, 0.0) color=FFD890 intensity=6.0
+ *   ...
+ * camera: 290.31, -35.59, -2.10, -0.58, -0.15, -0.00
+ * GI: running, 152034 work items, converged
+ * 
+ * + * @see Snapshot#poseString(Camera) + */ +public final class SceneDump { + + private SceneDump() { + // utility class + } + + /** + * Dumps scene state to a human-readable string. + * + * @param scene the scene (may be null to skip shape counts) + * @param lighting the lighting manager (may be null to skip lights) + * @param camera the camera (may be null to skip the pose) + * @param gi the GI system, or null when GI is not in use + * @return the dump, one fact per line + */ + public static String dump(final ShapeCollection scene, + final LightingManager lighting, + final Camera camera, + final GlobalIllumination gi) { + final StringBuilder sb = new StringBuilder("== SceneDump ==\n"); + + if (scene != null) { + int topLevel = 0; + for (final AbstractShape ignored : scene.getShapes()) + topLevel++; + sb.append("shapes: ").append(topLevel) + .append(" top-level, ").append(scene.getQueuedShapeCount()) + .append(" queued for rendering\n"); + } + + if (lighting != null) { + sb.append("lights: ").append(lighting.getLights().size()) + .append(" (ambient #") + .append(String.format("%06X", rgbOf(lighting.getAmbientLight()))) + .append(")\n"); + int i = 0; + for (final LightSource light : lighting.getLights()) { + sb.append(String.format(Locale.ROOT, + " [%d] pos=(%.1f, %.1f, %.1f) color=%06X intensity=%.1f%n", + i++, light.getPosition().x, light.getPosition().y, light.getPosition().z, + rgbOf(light.getColor()), light.getIntensity())); + } + } + + if (camera != null) + sb.append("camera: ").append(Snapshot.poseString(camera)).append('\n'); + + if (gi != null) { + sb.append("GI: "); + if (!gi.isRunning()) { + sb.append("stopped\n"); + } else { + sb.append("running, ").append(gi.getWorkItemCount()).append(" work items, ") + .append(gi.isConverged() ? "converged" : "converging").append('\n'); + } + } + + return sb.toString(); + } + + /** Packs an engine Color's r/g/b fields into a 0xRRGGBB int. */ + private static int rgbOf(final eu.svjatoslav.aukio.e3d.renderer.raster.Color color) { + return ((color.r & 0xFF) << 16) | ((color.g & 0xFF) << 8) | (color.b & 0xFF); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/headless/Snapshot.java b/src/main/java/eu/svjatoslav/aukio/e3d/headless/Snapshot.java new file mode 100644 index 0000000..010c15e --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/headless/Snapshot.java @@ -0,0 +1,200 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.headless; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.Camera; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection; +import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.Arrays; + +/** + * One-call facade for rendering a scene to an image without a window. + * + *

Assembles the same transform → sort → paint pipeline that + * {@code ViewPanel} drives on screen, but into an off-screen + * {@link RenderingContext} pixel buffer. No Swing frame, no X display, no + * render thread — safe to use from tests, doc tooling and batch jobs.

+ * + *

Example:

+ *
{@code
+ * ShapeCollection scene = new ShapeCollection();
+ * scene.addShape(myShape);
+ *
+ * LightingManager lighting = new LightingManager();
+ * lighting.setAmbientLight(Color.hex("181818"));
+ *
+ * BufferedImage image = Snapshot.render(scene, lighting,
+ *         "290.31, -35.59, -2.10, -0.58, -0.15, 0.0", 640, 480);
+ * Snapshot.save(image, "/tmp/snapshot.png");
+ * }
+ * + *

The pose string is the same "x, y, z, yaw, pitch, roll" format the + * demos print for camera positions, so a pose copy-pasted from a bug + * report reproduces the exact view.

+ * + * @see PixelAssertions for checking what got painted + * @see GoldenImage for comparing against a reference PNG + * @see SceneDump for inspecting the scene state behind an image + */ +public final class Snapshot { + + private Snapshot() { + // utility class + } + + /** + * Renders the scene once from the given pose string. + * + * @param scene the scene to render + * @param lighting lighting for the frame (may be null for unlit scenes) + * @param pose "x, y, z, yaw, pitch, roll" — same format demos print + * @param width image width in pixels + * @param height image height in pixels + * @return the rendered frame (background is black) + */ + public static BufferedImage render(final ShapeCollection scene, + final LightingManager lighting, + final String pose, + final int width, final int height) { + return render(scene, lighting, cameraFromPose(pose), width, height); + } + + /** + * Renders the scene once from the given camera. + * + * @param scene the scene to render + * @param lighting lighting for the frame (may be null for unlit scenes) + * @param camera positioned camera + * @param width image width in pixels + * @param height image height in pixels + * @return the rendered frame (background is black) + */ + public static BufferedImage render(final ShapeCollection scene, + final LightingManager lighting, + final Camera camera, + final int width, final int height) { + final RenderingContext ctx = new RenderingContext(width, height, 1); + ctx.lightingManager = lighting; + renderInto(scene, camera, ctx, 0); + return ctx.getImage(); + } + + /** + * Renders one frame into an existing context, filling the background + * with the given ARGB color first. Use a unique sentinel color when the + * caller needs to distinguish "nothing painted here" from "painted + * black" (e.g. hole detection in clipping tests). + * + * @param scene the scene to render + * @param camera positioned camera + * @param ctx target context (its {@code pixels} buffer is painted) + * @param backgroundArgb background fill applied before painting + */ + public static void renderInto(final ShapeCollection scene, + final Camera camera, + final RenderingContext ctx, + final int backgroundArgb) { + final Point3D location = camera.getTransform().getTranslation(); + ctx.viewerPosition.x = location.x; + ctx.viewerPosition.y = location.y; + ctx.viewerPosition.z = location.z; + + Arrays.fill(ctx.pixels, backgroundArgb); + Arrays.fill(ctx.depth, Float.NEGATIVE_INFINITY); + scene.transformShapes(camera, ctx); + scene.sortShapes(ctx.vertexSlot); + scene.paintShapes(ctx); + if (System.getProperty("aukio.zbuffer.dumpDepth") != null) + dumpDepth(ctx, System.getProperty("aukio.zbuffer.dumpDepth")); + ctx.frameNumber++; + } + + /** + * Debug helper: saves the depth buffer as a grayscale PNG (z = 1/w, + * log-scaled; untouched pixels black). Enabled per render via + * {@code -Daukio.zbuffer.dumpDepth=/path.png}. + */ + private static void dumpDepth(final RenderingContext ctx, + final String path) { + final int w = ctx.width; + final int h = ctx.height; + final BufferedImage img = new BufferedImage(w, h, + BufferedImage.TYPE_BYTE_GRAY); + final byte[] out = ((java.awt.image.DataBufferByte) + img.getRaster().getDataBuffer()).getData(); + double maxLog = 1; + for (int i = 0; i < w * h; i++) { + final float dw = ctx.depth[i]; + if (dw > 0) + maxLog = Math.max(maxLog, Math.log(1d / dw)); + } + for (int i = 0; i < w * h; i++) { + final float dw = ctx.depth[i]; + if (dw > 0) { + final double z = 1d / dw; + out[i] = (byte) (255 * Math.log(z) / maxLog); + } + } + try { + javax.imageio.ImageIO.write(img, "png", + new java.io.File(path)); + } catch (final java.io.IOException e) { + System.out.println("depth dump failed: " + e.getMessage()); + } + } + + /** + * Builds a camera from a "x, y, z, yaw, pitch, roll" pose string, as + * printed by demos (see {@link #poseString(Camera)}). + * + * @param pose the pose string; commas and extra whitespace tolerated + * @return a camera at that pose + */ + public static Camera cameraFromPose(final String pose) { + final String[] parts = pose.split(","); + if (parts.length != 6) + throw new IllegalArgumentException( + "pose must have 6 comma-separated values (x, y, z, yaw, pitch, roll), got: " + pose); + final double[] v = new double[6]; + for (int i = 0; i < 6; i++) + v[i] = Double.parseDouble(parts[i].trim()); + final Camera camera = new Camera(); + camera.getTransform().set(v[0], v[1], v[2], v[3], v[4], v[5]); + return camera; + } + + /** + * Formats a camera's pose as "x, y, z, yaw, pitch, roll" — the exact + * string {@link #cameraFromPose(String)} parses back. Intended for bug + * reports: paste the pose, reproduce the view. + * + * @param camera the camera to describe + * @return the pose string + */ + public static String poseString(final Camera camera) { + final Point3D p = camera.getTransform().getTranslation(); + final double[] angles = camera.getTransform().getRotation().toAngles(); + return String.format(java.util.Locale.ROOT, "%.2f, %.2f, %.2f, %.2f, %.2f, %.2f", + p.x, p.y, p.z, angles[0], angles[1], angles[2]); + } + + /** + * Saves an image as PNG. + * + * @param image the image to save + * @param path target file path + * @throws IOException on write failure + */ + public static void save(final BufferedImage image, final String path) throws IOException { + ImageIO.write(image, "png", new File(path)); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/headless/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/headless/package-info.java new file mode 100644 index 0000000..59fae56 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/headless/package-info.java @@ -0,0 +1,22 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Headless rendering toolkit: windowless snapshots, pixel assertions, + * golden-image comparison and scene-state dumps. + * + *

Everything here works without a display, a Swing frame or a render + * thread, driving the same transform/sort/paint pipeline the on-screen + * path uses. Built for automated verification (tests, doc tooling, AI + * agents), but equally useful for batch thumbnail generation.

+ * + *
    + *
  • {@link eu.svjatoslav.aukio.e3d.headless.Snapshot} — render a scene to a BufferedImage; pose strings
  • + *
  • {@link eu.svjatoslav.aukio.e3d.headless.PixelAssertions} — "did this region get painted?"
  • + *
  • {@link eu.svjatoslav.aukio.e3d.headless.GoldenImage} — compare against a reference PNG
  • + *
  • {@link eu.svjatoslav.aukio.e3d.headless.SceneDump} — shapes/lights/camera/GI state as text
  • + *
+ */ +package eu.svjatoslav.aukio.e3d.headless; diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/DiamondSquare.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/DiamondSquare.java new file mode 100644 index 0000000..500f96b --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/math/DiamondSquare.java @@ -0,0 +1,171 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +package eu.svjatoslav.aukio.e3d.math; + +import java.util.Random; + +/** + * Diamond-square algorithm for procedural noise generation. + *

+ * Generates realistic fractal noise suitable for terrain, textures, + * and other procedural content. The algorithm produces a 2D map + * where each value falls within the specified [min, max] range. + *

+ * Grid size must be 2^n + 1 (e.g., 3, 5, 9, 17, 33, 65, 129, 257). + * + * @see Diamond-square algorithm + */ +public final class DiamondSquare { + + private static final double DEFAULT_ROUGHNESS = 0.6; + + private DiamondSquare() { + } + + /** + * Generates a fractal noise map using the diamond-square algorithm. + * + * @param gridSize the size of the grid (must be 2^n + 1) + * @param min the minimum value in the output + * @param max the maximum value in the output + * @param seed random seed for reproducible results + * @return a 2D array of values in range [min, max] + * @throws IllegalArgumentException if gridSize is not 2^n + 1 + */ + public static double[][] generateMap(int gridSize, double min, double max, long seed) { + return generateMap(gridSize, min, max, DEFAULT_ROUGHNESS, seed); + } + + /** + * Generates a fractal noise map using the diamond-square algorithm with custom roughness. + * + * @param gridSize the size of the grid (must be 2^n + 1) + * @param min the minimum value in the output + * @param max the maximum value in the output + * @param roughness the roughness factor (0.0 to 1.0), higher values produce more variation + * @param seed random seed for reproducible results + * @return a 2D array of values in range [min, max] + * @throws IllegalArgumentException if gridSize is not 2^n + 1 + */ + public static double[][] generateMap(int gridSize, double min, double max, double roughness, long seed) { + if (!isValidGridSize(gridSize)) { + throw new IllegalArgumentException("Grid size must be 2^n + 1 (e.g., 65, 129, 257)"); + } + + Random random = new Random(seed); + double[][] map = new double[gridSize][gridSize]; + + map[0][0] = random.nextDouble(); + map[0][gridSize - 1] = random.nextDouble(); + map[gridSize - 1][0] = random.nextDouble(); + map[gridSize - 1][gridSize - 1] = random.nextDouble(); + + int stepSize = gridSize - 1; + double currentScale = roughness; + + while (stepSize > 1) { + int halfStep = stepSize / 2; + + for (int y = 0; y < gridSize - 1; y += stepSize) { + for (int x = 0; x < gridSize - 1; x += stepSize) { + double avg = (map[y][x] + + map[y][x + stepSize] + + map[y + stepSize][x] + + map[y + stepSize][x + stepSize]) / 4.0; + map[y + halfStep][x + halfStep] = + avg + (random.nextDouble() - 0.5) * currentScale; + } + } + + for (int y = 0; y < gridSize; y += stepSize) { + for (int x = 0; x < gridSize; x += stepSize) { + if (x + halfStep < gridSize) { + double avg = map[y][x]; + if (x - halfStep >= 0) { + avg += map[y][x - halfStep]; + } + if (x + stepSize < gridSize) { + avg += map[y][x + stepSize]; + } + if (y + halfStep < gridSize) { + avg += map[y + halfStep][x + halfStep]; + } else if (y - halfStep >= 0) { + avg += map[y - halfStep][x + halfStep]; + } + map[y][x + halfStep] = + avg / 4.0 + (random.nextDouble() - 0.5) * currentScale; + } + + if (y + halfStep < gridSize) { + double avg = map[y][x]; + if (y - halfStep >= 0) { + avg += map[y - halfStep][x]; + } + if (y + stepSize < gridSize) { + avg += map[y + stepSize][x]; + } + if (x + halfStep < gridSize) { + avg += map[y + halfStep][x + halfStep]; + } else if (x - halfStep >= 0) { + avg += map[y + halfStep][x - halfStep]; + } + map[y + halfStep][x] = + avg / 4.0 + (random.nextDouble() - 0.5) * currentScale; + } + } + } + + stepSize = halfStep; + currentScale *= roughness; + } + + normalize(map, min, max); + return map; + } + + private static void normalize(double[][] map, double min, double max) { + double actualMin = Double.MAX_VALUE; + double actualMax = Double.MIN_VALUE; + + for (double[] row : map) { + for (double value : row) { + if (value < actualMin) actualMin = value; + if (value > actualMax) actualMax = value; + } + } + + double range = actualMax - actualMin; + double targetRange = max - min; + + if (range == 0) { + for (int y = 0; y < map.length; y++) { + for (int x = 0; x < map[y].length; x++) { + map[y][x] = min; + } + } + return; + } + + for (int y = 0; y < map.length; y++) { + for (int x = 0; x < map[y].length; x++) { + map[y][x] = min + (map[y][x] - actualMin) / range * targetRange; + } + } + } + + /** + * Checks if the grid size is valid for the diamond-square algorithm. + * Valid sizes are 2^n + 1 (e.g., 3, 5, 9, 17, 33, 65, 129, 257). + * + * @param size the grid size to validate + * @return true if the size is valid + */ + public static boolean isValidGridSize(int size) { + if (size < 3) return false; + int value = size - 1; + return (value & (value - 1)) == 0; + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/Matrix3x3.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/Matrix3x3.java new file mode 100644 index 0000000..b7ca82d --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/math/Matrix3x3.java @@ -0,0 +1,67 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.math; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; + +/** + * A 3x3 matrix for 3D transformations. + * + *

Matrix elements are stored in row-major order:

+ *
+ * | m00 m01 m02 |
+ * | m10 m11 m12 |
+ * | m20 m21 m22 |
+ * 
+ * + * @see Point3D + */ +public class Matrix3x3 { + + public double m00; + public double m01; + public double m02; + public double m10; + public double m11; + public double m12; + public double m20; + public double m21; + public double m22; + + /** + * Creates a zero matrix. + */ + public Matrix3x3() { + } + + /** + * Returns an identity matrix. + * + * @return a new identity matrix + */ + public static Matrix3x3 identity() { + final Matrix3x3 m = new Matrix3x3(); + m.m00 = 1; + m.m11 = 1; + m.m22 = 1; + return m; + } + + /** + * Applies this matrix transformation to a point. + * + * @param in the input point (not modified) + * @param out the output point (will be modified) + */ + public void transform(final Point3D in, final Point3D out) { + final double x = m00 * in.x + m01 * in.y + m02 * in.z; + final double y = m10 * in.x + m11 * in.y + m12 * in.z; + final double z = m20 * in.x + m21 * in.y + m22 * in.z; + out.x = x; + out.y = y; + out.z = z; + } + +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/Quaternion.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/Quaternion.java new file mode 100644 index 0000000..c5d9fd3 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/math/Quaternion.java @@ -0,0 +1,281 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.math; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; + +import static java.lang.Math.cos; +import static java.lang.Math.sin; + +/** + * A unit quaternion representing a 3D rotation. + * + *

Quaternions provide a compact representation of rotations that avoids + * gimbal lock and enables smooth interpolation (slerp).

+ * + *

Usage example:

+ *
{@code
+ * // Create a rotation from yaw and pitch angles
+ * Quaternion rotation = Quaternion.fromAngles(0.5, -0.3);
+ *
+ * // Apply rotation to a point
+ * Point3D point = new Point3D(1, 0, 0);
+ * rotation.rotate(point);
+ *
+ * // Combine rotations
+ * Quaternion combined = rotation.multiply(otherRotation);
+ * }
+ * + * @see Matrix3x3 + * @see Transform + */ +public class Quaternion { + + /** + * The scalar (real) component of the quaternion. + */ + public double w; + + /** + * The i component (x-axis rotation factor). + */ + public double x; + + /** + * The j component (y-axis rotation factor). + */ + public double y; + + /** + * The k component (z-axis rotation factor). + */ + public double z; + + /** + * Creates an identity quaternion representing no rotation. + * Equivalent to Quaternion(1, 0, 0, 0). + */ + public Quaternion() { + this.w = 1; + this.x = 0; + this.y = 0; + this.z = 0; + } + + /** + * Creates a quaternion with the specified components. + * + * @param w the scalar component + * @param x the i component + * @param y the j component + * @param z the k component + */ + public Quaternion(final double w, final double x, final double y, final double z) { + this.w = w; + this.x = x; + this.y = y; + this.z = z; + } + + /** + * Returns the identity quaternion representing no rotation. + * + * @return the identity quaternion (1, 0, 0, 0) + */ + public static Quaternion identity() { + return new Quaternion(1, 0, 0, 0); + } + + /** + * Creates a quaternion from an axis-angle representation. + * + * @param axis the rotation axis (must be normalized) + * @param angle the rotation angle in radians + * @return a quaternion representing the rotation + */ + public static Quaternion fromAxisAngle(final Point3D axis, final double angle) { + final double halfAngle = angle / 2; + final double s = sin(halfAngle); + final double c = cos(halfAngle); + return new Quaternion(c, axis.x * s, axis.y * s, axis.z * s); + } + + /** + * Creates a quaternion from XZ (yaw) and YZ (pitch) Euler angles. + * + *

The rotation is composed as yaw (around Y axis) followed by + * pitch (around X axis). No roll rotation is applied.

+ * + *

For full 3-axis rotation, use {@link #fromAngles(double, double, double)}.

+ * + * @param angleXZ the angle around the XZ axis (yaw) in radians + * @param angleYZ the angle around the YZ axis (pitch) in radians + * @return a quaternion representing the combined rotation + */ + public static Quaternion fromAngles(final double angleXZ, final double angleYZ) { + return fromAngles(angleXZ, angleYZ, 0); + } + + /** + * Creates a quaternion from full Euler angles (yaw, pitch, roll). + * + *

Rotation order: yaw (Y) → pitch (X) → roll (Z). This is the standard + * Y-X-Z Euler order commonly used for object placement in 3D scenes.

+ * + *

Performance note: This method uses a direct Euler-to-quaternion + * formula to avoid intermediate allocations.

+ * + * @param yaw rotation around Y axis (horizontal heading) in radians + * @param pitch rotation around X axis (vertical tilt) in radians; + * positive values tilt upward + * @param roll rotation around Z axis (bank/tilt) in radians; + * positive values rotate clockwise when looking along +Z + * @return a quaternion representing the combined rotation + */ + public static Quaternion fromAngles(final double yaw, final double pitch, final double roll) { + // Half angles for the Euler-to-quaternion conversion + final double cy = cos(yaw * 0.5); + final double sy = sin(yaw * 0.5); + final double cp = cos(pitch * 0.5); + final double sp = sin(pitch * 0.5); + final double cr = cos(roll * 0.5); + final double sr = sin(roll * 0.5); + + // Direct formula for Y-X-Z Euler order with negated pitch + // Equivalent to: qRoll * qPitch(−pitch) * qYaw + return new Quaternion( + cr * cp * cy + sr * sp * sy, // w + -cr * sp * cy - sr * cp * sy, // x + cr * cp * sy - sr * sp * cy, // y + -cr * sp * sy + sr * cp * cy // z + ); + } + + /** + * Creates a copy of this quaternion. + * + * @return a new quaternion with the same component values + */ + public Quaternion clone() { + return new Quaternion(w, x, y, z); + } + + /** + * Copies the values from another quaternion into this one. + * + * @param other the quaternion to copy from + */ + public void set(final Quaternion other) { + this.w = other.w; + this.x = other.x; + this.y = other.y; + this.z = other.z; + } + + /** + * Multiplies this quaternion by another (Hamilton product). + * + * @param other the quaternion to multiply by + * @return a new quaternion representing the combined rotation + */ + public Quaternion multiply(final Quaternion other) { + return new Quaternion( + w * other.w - x * other.x - y * other.y - z * other.z, + w * other.x + x * other.w + y * other.z - z * other.y, + w * other.y - x * other.z + y * other.w + z * other.x, + w * other.z + x * other.y - y * other.x + z * other.w + ); + } + + /** + * Normalizes this quaternion to unit length. + * + * @return this quaternion (for chaining) + */ + public Quaternion normalize() { + final double len = Math.sqrt(w * w + x * x + y * y + z * z); + if (len > 0) { + w /= len; + x /= len; + y /= len; + z /= len; + } + return this; + } + + /** + * Returns the inverse (conjugate) of this unit quaternion. + * + *

For a unit quaternion, the inverse equals the conjugate: (w, -x, -y, -z). + * This represents the opposite rotation.

+ * + * @return a new quaternion representing the inverse rotation + */ + public Quaternion invert() { + return new Quaternion(w, -x, -y, -z); + } + + /** + * Converts this quaternion to a 3x3 rotation matrix. + * + * @return a new matrix representing this rotation + */ + public Matrix3x3 toMatrix3x3() { + final Matrix3x3 m = new Matrix3x3(); + copyToMatrix(m); + return m; + } + + /** + * Copies this quaternion's rotation to an existing 3x3 matrix. + * + *

This method avoids allocation by reusing an existing Matrix3x3 instance. + * Used by Transform to avoid per-vertex allocation during rotation.

+ * + * @param m the matrix to receive the rotation (modified in place) + */ + public void copyToMatrix(final Matrix3x3 m) { + m.m00 = 1 - 2 * (y * y + z * z); + m.m01 = 2 * (x * y - w * z); + m.m02 = 2 * (x * z + w * y); + + m.m10 = 2 * (x * y + w * z); + m.m11 = 1 - 2 * (x * x + z * z); + m.m12 = 2 * (y * z - w * x); + + m.m20 = 2 * (x * z - w * y); + m.m21 = 2 * (y * z + w * x); + m.m22 = 1 - 2 * (x * x + y * y); + } + + /** + * Converts this quaternion to a 3x3 rotation matrix. + * Alias for {@link #toMatrix3x3()} for API convenience. + * + * @return a new matrix representing this rotation + */ + public Matrix3x3 toMatrix() { + return toMatrix3x3(); + } + + /** + * Extracts Euler angles (yaw, pitch, roll) from this quaternion. + * + *

This is the inverse of {@link #fromAngles(double, double, double)}. + * Returns angles in the Y-X-Z Euler order used by this engine.

+ * + * @return array of {yaw, pitch, roll} in radians + */ + public double[] toAngles() { + final Matrix3x3 m = toMatrix3x3(); + + final double pitch = -Math.asin(Math.max(-1, Math.min(1, m.m21))); + final double yaw = -Math.atan2(m.m20, m.m22); + final double roll = -Math.atan2(m.m01, m.m11); + + return new double[]{yaw, pitch, roll}; + } + +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/Transform.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/Transform.java new file mode 100755 index 0000000..851c6c8 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/math/Transform.java @@ -0,0 +1,257 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.math; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; + +/** + * Represents a transformation in 3D space combining translation and rotation. + * + *

Transformations are applied in order: rotation first, then translation.

+ * + *

Performance optimization: The rotation matrix is cached and only + * recomputed when the rotation quaternion changes. This avoids allocating a + * new Matrix3x3 on every transform() call and avoids redundant quaternion-to-matrix + * conversions for vertices sharing the same transform.

+ * + *

Mutability convention:

+ *
    + *
  • Imperative verbs ({@code set}, {@code setTranslation}, {@code transform}) + * mutate this transform or the input point
  • + *
  • {@code with}-prefixed methods ({@code withTransformed}) + * return a new instance without modifying the original
  • + *
+ * + *

Thread safety: The transform phase is single-threaded (synchronized in + * ShapeCollection.transformShapes()), so no synchronization is needed for the cached matrix. + * The matrix is computed once per Transform per frame and reused for all vertices.

+ * + * @see Quaternion + * @see Point3D + */ +public class Transform implements Cloneable { + + /** + * The translation applied after rotation. + */ + private final Point3D translation; + + /** + * The rotation applied before translation. + */ + private final Quaternion rotation; + + /** + * Cached rotation matrix for performance. + * Lazily computed when first needed and reused for subsequent transform() calls. + */ + private Matrix3x3 cachedMatrix; + + /** + * Flag indicating whether the cached matrix needs to be recomputed. + * Set to true when rotation is modified via set() or invalidateCache(). + */ + private boolean matrixDirty = true; + + /** + * Creates a transform with no translation or rotation (identity transform). + */ + public Transform() { + translation = new Point3D(); + rotation = new Quaternion(); + } + + /** + * Creates a transform with the specified translation and no rotation. + * + * @param translation the translation + */ + public Transform(final Point3D translation) { + this.translation = translation; + rotation = new Quaternion(); + } + + /** + * Creates a transform with the specified translation and rotation from Euler angles. + * + * @param translation the translation + * @param yaw the angle around the Y axis (horizontal heading) in radians + * @param pitch the angle around the X axis (vertical tilt) in radians + * @return a new transform with the specified translation and rotation + */ + public static Transform fromAngles(final Point3D translation, final double yaw, final double pitch) { + return fromAngles(translation.x, translation.y, translation.z, yaw, pitch, 0); + } + + /** + * Creates a transform with translation and full Euler rotation. + * + *

Rotation order: yaw (Y) → pitch (X) → roll (Z). This is the standard + * Y-X-Z Euler order commonly used for object placement in 3D scenes.

+ * + * @param x translation X coordinate + * @param y translation Y coordinate + * @param z translation Z coordinate + * @param yaw rotation around Y axis (horizontal heading) in radians + * @param pitch rotation around X axis (vertical tilt) in radians + * @param roll rotation around Z axis (bank/tilt) in radians + * @return a new transform with the specified translation and rotation + */ + public static Transform fromAngles(final double x, final double y, final double z, + final double yaw, final double pitch, final double roll) { + final Transform t = new Transform(new Point3D(x, y, z)); + t.rotation.set(Quaternion.fromAngles(yaw, pitch, roll)); + return t; + } + + /** + * Creates a transform with the specified translation and rotation. + * + * @param translation the translation + * @param rotation the rotation (will be cloned) + */ + public Transform(final Point3D translation, final Quaternion rotation) { + this.translation = translation; + this.rotation = rotation.clone(); + } + + /** + * Creates a copy of this transform with cloned translation and rotation. + * + * @return a new transform with the same translation and rotation values + */ + @Override + public Transform clone() { + return new Transform(translation, rotation); + } + + /** + * Returns the rotation component of this transform. + * + *

Warning: If you modify the returned quaternion directly, you must + * call {@link #invalidateCache()} afterwards to ensure the cached rotation matrix + * is recomputed on the next call to {@link #transform(Point3D)}.

+ * + * @return the rotation quaternion (mutable reference) + */ + public Quaternion getRotation() { + return rotation; + } + + /** + * Invalidates the cached rotation matrix. + * + *

Call this method after directly modifying the rotation quaternion + * (obtained via {@link #getRotation()}) to ensure the matrix is recomputed + * on the next call to {@link #transform(Point3D)}.

+ * + *

This method is automatically called by {@link #set(double, double, double, double, double, double)}.

+ * + * @return this transform (for chaining) + */ + public Transform invalidateCache() { + matrixDirty = true; + return this; + } + + /** + * Returns the translation component of this transform. + * + * @return the translation point (mutable reference) + */ + public Point3D getTranslation() { + return translation; + } + + /** + * Applies this transform to a point: rotation followed by translation. + * + *

Uses a cached rotation matrix to avoid allocation and redundant computation. + * The matrix is computed once (lazily) and reused for all subsequent calls + * until {@link #invalidateCache()} is called.

+ * + * @param point the point to transform (modified in place) + * @see #withTransformed(Point3D) for the non-mutating version that returns a new point + */ + public void transform(final Point3D point) { + getRotationMatrix().transform(point, point); + point.add(translation); + } + + /** + * Returns the cached rotation matrix, computing it first if the cache + * is dirty or not yet initialized. + * + *

Package-private for internal use by {@link TransformStack}. + * Callers must not modify the returned matrix.

+ * + * @return the cached rotation matrix + */ + Matrix3x3 getRotationMatrix() { + // Lazily create and cache the rotation matrix + if (matrixDirty || cachedMatrix == null) { + if (cachedMatrix == null) { + cachedMatrix = new Matrix3x3(); + } + rotation.copyToMatrix(cachedMatrix); + matrixDirty = false; + } + return cachedMatrix; + } + + /** + * Returns a new point with this transform applied. + * The original point is not modified. + * + * @param point the point to transform + * @return a new Point3D with the transform applied + * @see #transform(Point3D) for the mutating version + */ + public Point3D withTransformed(final Point3D point) { + final Point3D result = new Point3D(point); + transform(result); + return result; + } + + /** + * Sets the translation for this transform by copying the values from the given point. + * + * @param translation the translation values to copy + * @return this transform (for chaining) + */ + public Transform setTranslation(final Point3D translation) { + this.translation.x = translation.x; + this.translation.y = translation.y; + this.translation.z = translation.z; + return this; + } + +/** + * Sets both translation and rotation from Euler angles. + * + *

Rotation order: yaw (Y) → pitch (X) → roll (Z). This is the standard + * Y-X-Z Euler order commonly used for object placement in 3D scenes.

+ * + *

This method invalidates the cached rotation matrix.

+ * + * @param x translation X coordinate + * @param y translation Y coordinate + * @param z translation Z coordinate + * @param yaw rotation around Y axis (horizontal heading) in radians + * @param pitch rotation around X axis (vertical tilt) in radians + * @param roll rotation around Z axis (bank/tilt) in radians + * @return this transform for chaining + */ + public Transform set(final double x, final double y, final double z, + final double yaw, final double pitch, final double roll) { + translation.x = x; + translation.y = y; + translation.z = z; + rotation.set(Quaternion.fromAngles(yaw, pitch, roll)); + matrixDirty = true; + return this; + } + +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/TransformStack.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/TransformStack.java new file mode 100644 index 0000000..58023c4 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/math/TransformStack.java @@ -0,0 +1,222 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.math; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; + +/** + * Stack of transforms applied to points during rendering. + * + *

Transforms are applied in reverse order (last added is applied first). + * This supports hierarchical scene graphs where child objects are positioned + * relative to their parent objects.

+ * + *

Example:

+ *
+ * There is a ship in the sea. The ship moves along the sea, and every object
+ * on the ship moves with it. Inside the ship there is a car. The car moves
+ * along the ship, and every object on the car moves with it.
+ *
+ * To calculate the world position of an object inside the car:
+ * 1. Apply an object's position relative to the car
+ * 2. Apply the car's position relative to the ship
+ * 3. Apply ship's position relative to the world
+ * 
+ * + *

Implementation: eager composition. Every transform is a rigid + * motion (rotation then translation), and compositions of rigid motions are + * closed and associative, so the whole stack collapses into a single + * equivalent transform. The stack maintains the composed rotation matrix and + * translation for each level: {@link #addTransform} composes the pushed + * transform with the previous level's composite, and {@link #transform} + * applies only the top-level composite. Per-point cost is one 3x3 + * matrix-vector multiply plus one addition, independent of stack depth. + * {@link #dropTransform} restores the parent composite for free.

+ * + *

Contract: composition is a snapshot taken at push time. Mutating + * a transform after pushing it has no effect on the stack until it is + * dropped and pushed again. The traversal rebuilds the stack every frame, + * so frame-to-frame changes are always picked up.

+ * + * @see Transform + */ +public class TransformStack { + + /** + * Maximum nesting depth of transforms. + * Fixed size for efficiency to avoid memory allocation during rendering. + */ + private static final int MAX_DEPTH = 100; + + /** + * Composed row-major 3x3 rotation matrices, 9 doubles per stack level. + * Level i holds the composition of all transforms pushed so far, in + * application order (level i's own transform applied first, level 0 last). + */ + private final double[] rotations = new double[MAX_DEPTH * 9]; + + /** + * Composed translations, 3 doubles per stack level. + */ + private final double[] translations = new double[MAX_DEPTH * 3]; + + /** + * The current number of transforms in the stack. + */ + private int transformsCount = 0; + + /** + * Creates a new empty transform stack. + */ + public TransformStack() { + } + + /** + * Creates a copy of another transform stack, duplicating its composed + * per-level transforms. Used to give each parallel transform worker its + * own stack preloaded with the same camera/parent state. + * + * @param source the stack to copy + */ + public TransformStack(final TransformStack source) { + loadFrom(source); + } + + /** + * Reinitializes this stack as a copy of {@code source} without + * allocating: the fixed-size arrays are reused. Used by the parallel + * transform coordinator to hand pooled stacks to chunk tasks. + * + * @param source the stack to copy + */ + public void loadFrom(final TransformStack source) { + transformsCount = source.transformsCount; + System.arraycopy(source.rotations, 0, rotations, 0, transformsCount * 9); + System.arraycopy(source.translations, 0, translations, 0, transformsCount * 3); + } + + /** + * Pushes a transform onto the stack, composing it with the current + * top-level composite. + * + *

If the previous composite is (Rp, tp) and the pushed transform is + * (Rn, tn), the new composite is R' = Rp * Rn, t' = Rp * tn + tp — + * the pushed transform applies first, then the previous composite.

+ * + * @param transform the transform to push (snapshotted at push time) + */ + public void addTransform(final Transform transform) { + final int i = transformsCount; + final int r = i * 9; + final int v = i * 3; + + final Matrix3x3 rm = transform.getRotationMatrix(); + final Point3D t = transform.getTranslation(); + + if (i == 0) { + rotations[r] = rm.m00; + rotations[r + 1] = rm.m01; + rotations[r + 2] = rm.m02; + rotations[r + 3] = rm.m10; + rotations[r + 4] = rm.m11; + rotations[r + 5] = rm.m12; + rotations[r + 6] = rm.m20; + rotations[r + 7] = rm.m21; + rotations[r + 8] = rm.m22; + translations[v] = t.x; + translations[v + 1] = t.y; + translations[v + 2] = t.z; + } else { + final int pr = r - 9; + final int pv = v - 3; + + final double a00 = rotations[pr]; + final double a01 = rotations[pr + 1]; + final double a02 = rotations[pr + 2]; + final double a10 = rotations[pr + 3]; + final double a11 = rotations[pr + 4]; + final double a12 = rotations[pr + 5]; + final double a20 = rotations[pr + 6]; + final double a21 = rotations[pr + 7]; + final double a22 = rotations[pr + 8]; + + rotations[r] = a00 * rm.m00 + a01 * rm.m10 + a02 * rm.m20; + rotations[r + 1] = a00 * rm.m01 + a01 * rm.m11 + a02 * rm.m21; + rotations[r + 2] = a00 * rm.m02 + a01 * rm.m12 + a02 * rm.m22; + rotations[r + 3] = a10 * rm.m00 + a11 * rm.m10 + a12 * rm.m20; + rotations[r + 4] = a10 * rm.m01 + a11 * rm.m11 + a12 * rm.m21; + rotations[r + 5] = a10 * rm.m02 + a11 * rm.m12 + a12 * rm.m22; + rotations[r + 6] = a20 * rm.m00 + a21 * rm.m10 + a22 * rm.m20; + rotations[r + 7] = a20 * rm.m01 + a21 * rm.m11 + a22 * rm.m21; + rotations[r + 8] = a20 * rm.m02 + a21 * rm.m12 + a22 * rm.m22; + + translations[v] = a00 * t.x + a01 * t.y + a02 * t.z + translations[pv]; + translations[v + 1] = a10 * t.x + a11 * t.y + a12 * t.z + translations[pv + 1]; + translations[v + 2] = a20 * t.x + a21 * t.y + a22 * t.z + translations[pv + 2]; + } + transformsCount++; + } + + /** + * Clears all transforms from the stack. + */ + public void clear() { + transformsCount = 0; + } + + /** + * Pops the most recently added transform from the stack. The parent + * level's composite is restored automatically. + */ + public void dropTransform() { + transformsCount--; + } + + /** + * Transforms a point through the whole stack by applying the top-level + * composed transform. Cost is independent of stack depth. + * + * @param coordinate the input coordinate (not modified) + * @param result the output coordinate (receives transformed result) + */ + public void transform(final Point3D coordinate, final Point3D result) { + if (transformsCount == 0) { + result.clone(coordinate); + return; + } + final int r = (transformsCount - 1) * 9; + final int v = (transformsCount - 1) * 3; + final double x = coordinate.x; + final double y = coordinate.y; + final double z = coordinate.z; + result.x = rotations[r] * x + rotations[r + 1] * y + rotations[r + 2] * z + translations[v]; + result.y = rotations[r + 3] * x + rotations[r + 4] * y + rotations[r + 5] * z + translations[v + 1]; + result.z = rotations[r + 6] * x + rotations[r + 7] * y + rotations[r + 8] * z + translations[v + 2]; + } + + /** + * Copies the fully composed top-level transform (rotations 0..8, then + * translations 0..2) into {@code out} (length ≥ 12), for bulk + * loops that apply the same matrix to thousands of vertices + * ({@code TriangleMeshBlock}). Identity when the stack is empty. + * Applying it with the same expression order as + * {@link #transform(Point3D, Point3D)} yields bit-identical results. + * + * @param out destination array, length at least 12 + */ + public void getTopTransform(final double[] out) { + if (transformsCount == 0) { + out[0] = 1; out[1] = 0; out[2] = 0; + out[3] = 0; out[4] = 1; out[5] = 0; + out[6] = 0; out[7] = 0; out[8] = 1; + out[9] = 0; out[10] = 0; out[11] = 0; + return; + } + final int r = (transformsCount - 1) * 9; + final int v = (transformsCount - 1) * 3; + System.arraycopy(rotations, r, out, 0, 9); + System.arraycopy(translations, v, out, 9, 3); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/Vertex.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/Vertex.java new file mode 100644 index 0000000..0eb8bcd --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/math/Vertex.java @@ -0,0 +1,293 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.math; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; + +/** + * A vertex in 3D space with transformation and screen projection support. + * + *

A vertex represents a corner point of a polygon or polyhedron. In addition to + * the 3D coordinate, it stores the transformed position (relative to viewer) and + * the projected screen coordinates for rendering.

+ * + *

Coordinate spaces:

+ *
    + *
  • {@link #coordinate} - Original position in local/model space
  • + *
  • {@link #transformedCoordinate} - Position relative to viewer (camera space)
  • + *
  • {@link #onScreenCoordinate} - 2D screen position after perspective projection
  • + *
+ * + *

Example:

+ *
{@code
+ * Vertex v = new Vertex(new Point3D(10, 20, 30));
+ * v.calculateLocationRelativeToViewer(transformStack, renderContext);
+ * if (v.transformedCoordinate.z > 0) {
+ *     // Vertex is in front of the camera
+ * }
+ * }
+ * + * @see Point3D + * @see TransformStack + */ +public class Vertex { + + /** + * Vertex coordinate in local/model 3D space. + */ + public Point3D coordinate; + + /** + * Vertex coordinate relative to the viewer after transformation (camera + * space), per buffer slot. Slot parity lets the transform phase of the + * NEXT frame write slot B while the paint phase of the current frame + * still reads slot A (double-buffered pipeline). Never access directly: + * use {@link #transformedCoordinate(RenderingContext)}. + */ + private final Point3D transformedCoordinate0 = new Point3D(); + private final Point3D transformedCoordinate1 = new Point3D(); + private final Point3D transformedCoordinate2 = new Point3D(); + + /** + * Vertex position on screen in pixels, per buffer slot. + * Use {@link #onScreenCoordinate(RenderingContext)}. + */ + private final Point2D onScreenCoordinate0 = new Point2D(); + private final Point2D onScreenCoordinate1 = new Point2D(); + private final Point2D onScreenCoordinate2 = new Point2D(); + + /** + * Texture coordinate for UV mapping (optional). + */ + public Point2D textureCoordinate; + + /** + * Normal vector for this vertex (optional). + * Used by CSG operations for smooth interpolation during polygon splitting. + * Null for non-CSG usage; existing rendering code ignores this field. + */ + public Point3D normal; + + + /** + * The transform cycle when each slot was last transformed (for + * caching). Keyed by {@link RenderingContext#transformCycleId}, NOT + * by frameNumber: in stereo mode both eye passes share the same + * parity context and therefore the same frameNumber, while using + * different slots — a frameNumber-based key lets one eye's pass + * falsely hit the cache entry the other eye wrote for the same slot + * an odd number of passes earlier, leaving opposite-eye (wrong + * viewport-offset) screen coordinates in the slot: the affected eye + * paints fully off-viewport, i.e. a black half-frame (observed as + * violent left/right flashing, 2026-09-09). transformCycleId is + * unique per pass, so a hit always means "already transformed within + * THIS pass" — the only correct dedupe semantics. + */ + private long lastTransformCycle0 = -1; + private long lastTransformCycle1 = -1; + private long lastTransformCycle2 = -1; + + /** + * Creates a vertex at the origin (0, 0, 0) with no texture coordinate. + */ + public Vertex() { + this(new Point3D()); + } + + /** + * Creates a vertex at the specified position with no texture coordinate. + * + * @param coordinate the 3D position of this vertex + */ + public Vertex(final Point3D coordinate) { + this(coordinate, null); + } + + /** + * Creates a vertex at the specified position with an optional texture coordinate. + * + * @param coordinate the 3D position of this vertex + * @param textureCoordinate the UV texture coordinate, or {@code null} for none + */ + public Vertex(final Point3D coordinate, final Point2D textureCoordinate) { + this.coordinate = coordinate; + this.textureCoordinate = textureCoordinate; + } + + /** + * Returns the camera-space coordinate for the rendering context's buffer + * slot. Valid only after this vertex was transformed for that slot's + * current frame. + * + * @param renderContext the rendering context (selects the buffer slot) + * @return the transformed coordinate (camera space) for the active slot + */ + public Point3D transformedCoordinate(final RenderingContext renderContext) { + // Dual fields, not a slot array: measured 2026-09-05 (400-sphere + // scene) that array indexing adds a second dependent load per access + // and cost ~60% of transform phase time; a perfectly-predicted + // branch + direct field load is free. + final int slot = renderContext.vertexSlot; + return slot == 0 ? transformedCoordinate0 + : slot == 1 ? transformedCoordinate1 : transformedCoordinate2; + } + + /** + * Returns the screen-space position for the rendering context's buffer + * slot. + * + * @param renderContext the rendering context (selects the buffer slot) + * @return the on-screen coordinate (pixels) for the active slot + */ + public Point2D onScreenCoordinate(final RenderingContext renderContext) { + final int slot = renderContext.vertexSlot; + return slot == 0 ? onScreenCoordinate0 + : slot == 1 ? onScreenCoordinate1 : onScreenCoordinate2; + } + + + /** + * Transforms this vertex from model space to screen space. + * + *

This method applies the transform stack to compute the vertex position + * relative to the viewer, then projects it to 2D screen coordinates. + * Results are cached per-frame per-slot to avoid redundant calculations.

+ * + * @param transforms the transform stack to apply (world-to-camera transforms) + * @param renderContext the rendering context providing projection parameters + */ + public void calculateLocationRelativeToViewer(final TransformStack transforms, + final RenderingContext renderContext) { + + final Point3D transformedCoordinate; + final Point2D onScreenCoordinate; + switch (renderContext.vertexSlot) { + case 0: + if (lastTransformCycle0 == renderContext.transformCycleId) + return; + lastTransformCycle0 = renderContext.transformCycleId; + transformedCoordinate = transformedCoordinate0; + onScreenCoordinate = onScreenCoordinate0; + break; + case 1: + if (lastTransformCycle1 == renderContext.transformCycleId) + return; + lastTransformCycle1 = renderContext.transformCycleId; + transformedCoordinate = transformedCoordinate1; + onScreenCoordinate = onScreenCoordinate1; + break; + default: + if (lastTransformCycle2 == renderContext.transformCycleId) + return; + lastTransformCycle2 = renderContext.transformCycleId; + transformedCoordinate = transformedCoordinate2; + onScreenCoordinate = onScreenCoordinate2; + break; + } + transforms.transform(coordinate, transformedCoordinate); + onScreenCoordinate.x = ((transformedCoordinate.x / transformedCoordinate.z) * renderContext.projectionScale); + onScreenCoordinate.y = ((transformedCoordinate.y / transformedCoordinate.z) * renderContext.projectionScale); + onScreenCoordinate.add(renderContext.centerCoordinate); + onScreenCoordinate.x += renderContext.stereoViewportOffsetX; + } + + /** + * Writes a camera-space position directly into this vertex's slot state + * and projects it to screen coordinates, bypassing the transform stack. + * + *

Used by near-plane clipping ({@code AbstractCoordinateShape}), which + * creates intersection vertices that exist ONLY in camera space — there + * is no model-space coordinate to transform. The given z must be > 0 + * (clip against the near plane guarantees z == nearPlaneDistance).

+ * + * @param x camera-space X + * @param y camera-space Y + * @param z camera-space Z (depth in front of viewer, > 0) + * @param renderContext the rendering context (selects the buffer slot and + * provides projection parameters) + */ + public void setCameraSpaceCoordinate(final double x, final double y, final double z, + final RenderingContext renderContext) { + final Point3D transformedCoordinate; + final Point2D onScreenCoordinate; + switch (renderContext.vertexSlot) { + case 0: + transformedCoordinate = transformedCoordinate0; + onScreenCoordinate = onScreenCoordinate0; + break; + case 1: + transformedCoordinate = transformedCoordinate1; + onScreenCoordinate = onScreenCoordinate1; + break; + default: + transformedCoordinate = transformedCoordinate2; + onScreenCoordinate = onScreenCoordinate2; + break; + } + transformedCoordinate.x = x; + transformedCoordinate.y = y; + transformedCoordinate.z = z; + onScreenCoordinate.x = ((x / z) * renderContext.projectionScale); + onScreenCoordinate.y = ((y / z) * renderContext.projectionScale); + onScreenCoordinate.add(renderContext.centerCoordinate); + onScreenCoordinate.x += renderContext.stereoViewportOffsetX; + } + + // ========== CSG support methods ========== + + /** + * Creates a deep copy of this vertex. + * Clones the coordinate, normal (if present), and texture coordinate (if present). + * The transformedCoordinate and onScreenCoordinate are not cloned (they are computed per-frame). + * + * @return a new Vertex with cloned data + */ + public Vertex clone() { + final Vertex result = new Vertex(new Point3D(coordinate), + textureCoordinate != null ? new Point2D(textureCoordinate) : null); + if (normal != null) { + result.normal = new Point3D(normal); + } + return result; + } + + /** + * Flips the orientation of this vertex by negating the normal vector. + * Called when the orientation of a polygon is flipped during CSG operations. + * If normal is null, this method does nothing. + */ + public void flip() { + if (normal != null) { + normal = normal.withNegated(); + } + } + + /** + * Creates a new vertex between this vertex and another by linearly interpolating + * all properties using parameter t. + * + *

Interpolates: position, normal (if present), and texture coordinate (if present).

+ * + * @param other the other vertex to interpolate towards + * @param t the interpolation parameter (0 = this vertex, 1 = other vertex) + * @return a new Vertex representing the interpolated position + */ + public Vertex interpolate(final Vertex other, final double t) { + final Vertex result = new Vertex( + coordinate.interpolate(other.coordinate, t), + (textureCoordinate != null && other.textureCoordinate != null) + ? new Point2D( + textureCoordinate.x + (other.textureCoordinate.x - textureCoordinate.x) * t, + textureCoordinate.y + (other.textureCoordinate.y - textureCoordinate.y) * t) + : null + ); + if (normal != null && other.normal != null) { + result.normal = normal.interpolate(other.normal, t); + } + return result; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/package-info.java new file mode 100644 index 0000000..3d7ab5e --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/math/package-info.java @@ -0,0 +1,9 @@ +/** + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + *

+ * Math that is needed for the project. + */ + +package eu.svjatoslav.aukio.e3d.math; + diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/package-info.java new file mode 100644 index 0000000..b8092d1 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/package-info.java @@ -0,0 +1,7 @@ +/** + * This is root package for 3D engine. Since package name cannot start with a digit, it is named "e3d" instead, + * which stands for "Engine 3D". + */ + +package eu.svjatoslav.aukio.e3d; + diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/IntegerPoint.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/IntegerPoint.java new file mode 100644 index 0000000..01acee1 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/IntegerPoint.java @@ -0,0 +1,39 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.octree; + +/** + * Point in 3D space with integer coordinates. Used for octree voxel positions. + */ +public class IntegerPoint +{ + /** X coordinate. */ + public int x; + /** Y coordinate. */ + public int y; + /** Z coordinate. */ + public int z = 0; + + /** + * Creates a point at the origin (0, 0, 0). + */ + public IntegerPoint() + { + } + + /** + * Creates a point with the specified coordinates. + * + * @param x the X coordinate + * @param y the Y coordinate + * @param z the Z coordinate + */ + public IntegerPoint(final int x, final int y, final int z) + { + this.x = x; + this.y = y; + this.z = z; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/OctreeVolume.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/OctreeVolume.java new file mode 100755 index 0000000..5ae69d8 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/OctreeVolume.java @@ -0,0 +1,1102 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.octree; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.Ray; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; + +import static java.lang.Integer.max; +import static java.lang.Integer.min; + +/** + * Sparse voxel octree for 3D volume storage and ray tracing. + * + *

The octree represents a 3D volume with three cell types:

+ *
    + *
  • UNUSED - Empty cell, not yet allocated
  • + *
  • SOLID - Contains color and illumination data
  • + *
  • CLUSTER - Contains pointers to 8 child cells (for subdivision)
  • + *
+ * + *

Cell data is stored in parallel arrays ({@code cell1} through {@code cell8}) + * for memory efficiency. Each array stores different aspects of cell data.

+ * + * @see eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.RayTracer + * @see eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.Ray + */ +public class OctreeVolume { + + /** Return value indicating no intersection during ray tracing. */ + public static final int TRACE_NO_HIT = -1; + + /** Cell state marker for solid cells. */ + private static final int CELL_STATE_SOLID = -2; + + /** Cell state marker for unused/empty cells. */ + private static final int CELL_STATE_UNUSED = -1; + + /** Cell data array 1: stores cell state and first child pointer. */ + public int[] cell1; + /** Cell data array 2: stores color values. */ + public int[] cell2; + /** Cell data array 3: stores illumination values. */ + public int[] cell3; + /** Cell data array 4: stores child pointer 4. */ + public int[] cell4; + /** Cell data array 5: stores child pointer 5. */ + public int[] cell5; + /** Cell data array 6: stores child pointer 6. */ + public int[] cell6; + /** Cell data array 7: stores child pointer 7. */ + public int[] cell7; + /** Cell data array 8: stores child pointer 8. */ + public int[] cell8; + + /** + * Pointer to the next unused cell in the allocation buffer. + */ + public int cellAllocationPointer = 0; + + /** Number of currently allocated cells. */ + public int usedCellsCount = 0; + + /** Size of the root (master) cell in world units. */ + public int masterCellSize; + + /** + * Creates a new octree volume with default buffer size (1.5M cells) + * and master cell size of 256*64 units. + */ + public OctreeVolume() { + initWorld(1500000, 256 * 64); + } + + /** + * Subdivides a solid cell into 8 child cells, each with the same color and illumination. + * + * @param pointer the cell to break up + */ + public void breakSolidCell(final int pointer) { + final int color = getCellColor(pointer); + final int illumination = getCellIllumination(pointer); + + cell1[pointer] = makeNewCell(color, illumination); + cell2[pointer] = makeNewCell(color, illumination); + cell3[pointer] = makeNewCell(color, illumination); + cell4[pointer] = makeNewCell(color, illumination); + cell5[pointer] = makeNewCell(color, illumination); + cell6[pointer] = makeNewCell(color, illumination); + cell7[pointer] = makeNewCell(color, illumination); + cell8[pointer] = makeNewCell(color, illumination); + } + + /** + * Clears the cell. + * @param pointer Pointer to the cell. + */ + public void clearCell(final int pointer) { + cell1[pointer] = 0; + cell2[pointer] = 0; + cell3[pointer] = 0; + cell4[pointer] = 0; + + cell5[pointer] = 0; + cell6[pointer] = 0; + cell7[pointer] = 0; + cell8[pointer] = 0; + } + + /** + * Marks a cell as deleted and returns it to the unused pool. + * + * @param cellPointer the cell to delete + */ + public void deleteCell(final int cellPointer) { + clearCell(cellPointer); + cell1[cellPointer] = CELL_STATE_UNUSED; + usedCellsCount--; + } + + /** + * Tests whether a ray intersects with a cubic region. + * + * @param cubeX the X center of the cube + * @param cubeY the Y center of the cube + * @param cubeZ the Z center of the cube + * @param cubeSize the half-size of the cube + * @param r the ray to test + * @return intersection type code, or 0 if no intersection + */ + public int doesIntersect(final int cubeX, final int cubeY, final int cubeZ, + final int cubeSize, final Ray r) { + + // ray starts inside the cube + if ((cubeX - cubeSize) < r.origin.x) + if ((cubeX + cubeSize) > r.origin.x) + if ((cubeY - cubeSize) < r.origin.y) + if ((cubeY + cubeSize) > r.origin.y) + if ((cubeZ - cubeSize) < r.origin.z) + if ((cubeZ + cubeSize) > r.origin.z) { + r.hitPoint = r.origin.clone(); + return 1; + } + // back face + if (r.direction.z > 0) + if ((cubeZ - cubeSize) > r.origin.z) { + final double mult = ((cubeZ - cubeSize) - r.origin.z) / r.direction.z; + final double hitX = (r.direction.x * mult) + r.origin.x; + if ((cubeX - cubeSize) < hitX) + if ((cubeX + cubeSize) > hitX) { + final double hitY = (r.direction.y * mult) + r.origin.y; + if ((cubeY - cubeSize) < hitY) + if ((cubeY + cubeSize) > hitY) { + r.hitPoint = new Point3D(hitX, hitY, cubeZ + - cubeSize); + return 2; + } + } + } + + // up face + if (r.direction.y > 0) + if ((cubeY - cubeSize) > r.origin.y) { + final double mult = ((cubeY - cubeSize) - r.origin.y) / r.direction.y; + final double hitX = (r.direction.x * mult) + r.origin.x; + if ((cubeX - cubeSize) < hitX) + if ((cubeX + cubeSize) > hitX) { + final double hitZ = (r.direction.z * mult) + r.origin.z; + if ((cubeZ - cubeSize) < hitZ) + if ((cubeZ + cubeSize) > hitZ) { + r.hitPoint = new Point3D(hitX, cubeY - cubeSize, + hitZ); + return 3; + } + } + } + + // left face + if (r.direction.x > 0) + if ((cubeX - cubeSize) > r.origin.x) { + final double mult = ((cubeX - cubeSize) - r.origin.x) / r.direction.x; + final double hitY = (r.direction.y * mult) + r.origin.y; + if ((cubeY - cubeSize) < hitY) + if ((cubeY + cubeSize) > hitY) { + final double hitZ = (r.direction.z * mult) + r.origin.z; + if ((cubeZ - cubeSize) < hitZ) + if ((cubeZ + cubeSize) > hitZ) { + r.hitPoint = new Point3D(cubeX - cubeSize, hitY, + hitZ); + return 4; + } + } + } + + // front face + if (r.direction.z < 0) + if ((cubeZ + cubeSize) < r.origin.z) { + final double mult = ((cubeZ + cubeSize) - r.origin.z) / r.direction.z; + final double hitX = (r.direction.x * mult) + r.origin.x; + if ((cubeX - cubeSize) < hitX) + if ((cubeX + cubeSize) > hitX) { + final double hitY = (r.direction.y * mult) + r.origin.y; + if ((cubeY - cubeSize) < hitY) + if ((cubeY + cubeSize) > hitY) { + r.hitPoint = new Point3D(hitX, hitY, cubeZ + + cubeSize); + return 5; + } + } + } + + // down face + if (r.direction.y < 0) + if ((cubeY + cubeSize) < r.origin.y) { + final double mult = ((cubeY + cubeSize) - r.origin.y) / r.direction.y; + final double hitX = (r.direction.x * mult) + r.origin.x; + if ((cubeX - cubeSize) < hitX) + if ((cubeX + cubeSize) > hitX) { + final double hitZ = (r.direction.z * mult) + r.origin.z; + if ((cubeZ - cubeSize) < hitZ) + if ((cubeZ + cubeSize) > hitZ) { + r.hitPoint = new Point3D(hitX, cubeY + cubeSize, + hitZ); + return 6; + } + } + } + + // right face + if (r.direction.x < 0) + if ((cubeX + cubeSize) < r.origin.x) { + final double mult = ((cubeX + cubeSize) - r.origin.x) / r.direction.x; + final double hitY = (r.direction.y * mult) + r.origin.y; + if ((cubeY - cubeSize) < hitY) + if ((cubeY + cubeSize) > hitY) { + final double hitZ = (r.direction.z * mult) + r.origin.z; + if ((cubeZ - cubeSize) < hitZ) + if ((cubeZ + cubeSize) > hitZ) { + r.hitPoint = new Point3D(cubeX + cubeSize, hitY, + hitZ); + return 7; + } + } + } + return 0; + } + + /** + * Fills a 3D rectangular region with solid cells of the given color. + * + * @param p1 one corner of the rectangle + * @param p2 the opposite corner of the rectangle + * @param color the color to fill with + */ + public void fillRectangle(IntegerPoint p1, IntegerPoint p2, Color color) { + + int x1 = min(p1.x, p2.x); + int x2 = max(p1.x, p2.x); + int y1 = min(p1.y, p2.y); + int y2 = max(p1.y, p2.y); + int z1 = min(p1.z, p2.z); + int z2 = max(p1.z, p2.z); + + for (int x = x1; x <= x2; x++) + for (int y = y1; y <= y2; y++) + for (int z = z1; z <= z2; z++) + putCell(x, y, z, 0, 0, 0, masterCellSize, 0, color); + } + + /** + * Returns the color value stored in a solid cell. + * + * @param pointer the cell pointer + * @return the packed RGB color value + */ + public int getCellColor(final int pointer) { + return cell2[pointer]; + } + + /** + * Returns the illumination value stored in a solid cell. + * + * @param pointer the cell pointer + * @return the packed RGB illumination value + */ + public int getCellIllumination(final int pointer) { + return cell3[pointer]; + } + + /** + * Initializes the octree storage arrays with the specified buffer size and root cell size. + * + * @param bufferLength the number of cells to allocate space for + * @param masterCellSize the size of the root cell in world units + */ + public void initWorld(final int bufferLength, final int masterCellSize) { + // System.out.println("Initializing new world"); + + // initialize world storage buffer + this.masterCellSize = masterCellSize; + + cell1 = new int[bufferLength]; + cell2 = new int[bufferLength]; + cell3 = new int[bufferLength]; + cell4 = new int[bufferLength]; + + cell5 = new int[bufferLength]; + cell6 = new int[bufferLength]; + cell7 = new int[bufferLength]; + cell8 = new int[bufferLength]; + + for (int i = 0; i < bufferLength; i++) + cell1[i] = CELL_STATE_UNUSED; + + // initialize master cell + clearCell(0); + } + + /** + * Checks if the cell at the given pointer is a solid (leaf) cell. + * + * @param pointer the cell pointer to check + * @return {@code true} if the cell is solid + */ + public boolean isCellSolid(final int pointer) { + return cell1[pointer] == CELL_STATE_SOLID; + } + + /** + * Scans cells arrays and returns pointer to found unused cell. + * @return pointer to found unused cell + */ + public int getNewCellPointer() { + while (true) { + // ensure that cell allocation pointer is in bounds + if (cellAllocationPointer >= cell1.length) + cellAllocationPointer = 0; + + if (cell1[cellAllocationPointer] == CELL_STATE_UNUSED) { + // unused cell found + clearCell(cellAllocationPointer); + + usedCellsCount++; + return cellAllocationPointer; + } else + cellAllocationPointer++; + } + } + + /** + * Allocates a new solid cell with the given color and illumination. + * + * @param color the color value for the new cell + * @param illumination the illumination value for the new cell + * @return the pointer to the newly allocated cell + */ + public int makeNewCell(final int color, final int illumination) { + final int pointer = getNewCellPointer(); + markCellAsSolid(pointer); + setCellColor(pointer, color); + setCellIllumination(pointer, illumination); + return pointer; + } + + /** + * Mark cell as solid. + * + * @param pointer pointer to cell + */ + public void markCellAsSolid(final int pointer) { + cell1[pointer] = CELL_STATE_SOLID; + } + + /** + * Stores a voxel at the given world coordinates with the specified color. + * + * @param x the X coordinate + * @param y the Y coordinate + * @param z the Z coordinate + * @param color the color of the voxel + */ + public void putCell(final int x, final int y, final int z, final Color color) { + putCell(x, y, z, 0, 0, 0, masterCellSize, 0, color); + } + + private void putCell(final int x, final int y, final int z, + final int cellX, final int cellY, final int cellZ, + final int cellSize, final int cellPointer, final Color color) { + + if (cellSize > 1) { + + // if case of big cell + if (isCellSolid(cellPointer)) { + + // if cell is already a needed color, do nothing + if (getCellColor(cellPointer) == color.toInt()) + return; + + // otherwise break cell up + breakSolidCell(cellPointer); + + // continue, as if it is cluster now + } + + // decide which subcube to use + int[] subCubeArray; + int subX, subY, subZ; + + if (x > cellX) { + subX = (cellSize / 2) + cellX; + if (y > cellY) { + subY = (cellSize / 2) + cellY; + if (z > cellZ) { + subZ = (cellSize / 2) + cellZ; + // 7 + subCubeArray = cell7; + } else { + subZ = (-cellSize / 2) + cellZ; + // 3 + subCubeArray = cell3; + } + } else { + subY = (-cellSize / 2) + cellY; + if (z > cellZ) { + subZ = (cellSize / 2) + cellZ; + // 6 + subCubeArray = cell6; + } else { + subZ = (-cellSize / 2) + cellZ; + // 2 + subCubeArray = cell2; + } + } + } else { + subX = (-cellSize / 2) + cellX; + if (y > cellY) { + subY = (cellSize / 2) + cellY; + if (z > cellZ) { + subZ = (cellSize / 2) + cellZ; + // 8 + subCubeArray = cell8; + } else { + subZ = (-cellSize / 2) + cellZ; + // 4 + subCubeArray = cell4; + } + } else { + subY = (-cellSize / 2) + cellY; + if (z > cellZ) { + subZ = (cellSize / 2) + cellZ; + // 5 + subCubeArray = cell5; + } else { + subZ = (-cellSize / 2) + cellZ; + // 1 + subCubeArray = cell1; + } + } + } + + int subCubePointer; + if (subCubeArray[cellPointer] == 0) { + // create empty cluster + subCubePointer = getNewCellPointer(); + subCubeArray[cellPointer] = subCubePointer; + } else + subCubePointer = subCubeArray[cellPointer]; + + putCell(x, y, z, subX, subY, subZ, cellSize / 2, subCubePointer, + color); + } else { + cell1[cellPointer] = CELL_STATE_SOLID; + cell2[cellPointer] = color.toInt(); + cell3[cellPointer] = CELL_STATE_UNUSED; + // System.out.println("Cell written!"); + } + } + + /** + * Sets the color value for the cell at the given pointer. + * + * @param pointer the cell pointer + * @param color the color value to set + */ + public void setCellColor(final int pointer, final int color) { + cell2[pointer] = color; + } + + /** + * Sets the illumination value for the cell at the given pointer. + * + * @param pointer the cell pointer + * @param illumination the illumination value to set + */ + public void setCellIllumination(final int pointer, final int illumination) { + cell3[pointer] = illumination; + } + + /** + * Traces a ray through the octree to find an intersecting solid cell. + * + * @param cellX the X coordinate of the current cell center + * @param cellY the Y coordinate of the current cell center + * @param cellZ the Z coordinate of the current cell center + * @param cellSize the size of the current cell + * @param pointer the pointer to the current cell + * @param ray the ray to trace + * @return pointer to intersecting cell or TRACE_NO_HIT if no intersection + */ + public int traceCell(final int cellX, final int cellY, final int cellZ, + final int cellSize, final int pointer, final Ray ray) { + if (isCellSolid(pointer)) { + // solid cell + if (doesIntersect(cellX, cellY, cellZ, cellSize, ray) != 0) { + ray.hitCellSize = cellSize; + ray.hitCellX = cellX; + ray.hitCellY = cellY; + ray.hitCellZ = cellZ; + return pointer; + } + return TRACE_NO_HIT; + } else // cluster + if (doesIntersect(cellX, cellY, cellZ, cellSize, ray) != 0) { + final int halfOfCellSize = cellSize / 2; + int rayIntersectionResult; + + if (ray.origin.x > cellX) { + if (ray.origin.y > cellY) { + if (ray.origin.z > cellZ) { + // 7 + // 6 8 3 5 2 4 1 + + if (cell7[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY + halfOfCellSize, + cellZ + halfOfCellSize, halfOfCellSize, + cell7[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell6[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY - halfOfCellSize, + cellZ + halfOfCellSize, halfOfCellSize, + cell6[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell8[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY + halfOfCellSize, + cellZ + halfOfCellSize, halfOfCellSize, + cell8[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell3[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY + halfOfCellSize, + cellZ - halfOfCellSize, halfOfCellSize, + cell3[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell2[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY - halfOfCellSize, + cellZ - halfOfCellSize, halfOfCellSize, + cell2[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell4[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY + halfOfCellSize, + cellZ - halfOfCellSize, halfOfCellSize, + cell4[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell5[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY - halfOfCellSize, + cellZ + halfOfCellSize, halfOfCellSize, + cell5[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell1[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY - halfOfCellSize, + cellZ - halfOfCellSize, halfOfCellSize, + cell1[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + } else { + // 3 + // 2 4 7 1 6 8 5 + if (cell3[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY + halfOfCellSize, + cellZ - halfOfCellSize, halfOfCellSize, + cell3[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell2[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY - halfOfCellSize, + cellZ - halfOfCellSize, halfOfCellSize, + cell2[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell4[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY + halfOfCellSize, + cellZ - halfOfCellSize, halfOfCellSize, + cell4[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell7[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY + halfOfCellSize, + cellZ + halfOfCellSize, halfOfCellSize, + cell7[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell6[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY - halfOfCellSize, + cellZ + halfOfCellSize, halfOfCellSize, + cell6[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell8[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY + halfOfCellSize, + cellZ + halfOfCellSize, halfOfCellSize, + cell8[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell1[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY - halfOfCellSize, + cellZ - halfOfCellSize, halfOfCellSize, + cell1[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell5[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY - halfOfCellSize, + cellZ + halfOfCellSize, halfOfCellSize, + cell5[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + } + } else if (ray.origin.z > cellZ) { + // 6 + // 5 2 7 8 1 3 4 + if (cell6[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY - halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell6[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell7[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY + halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell7[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell2[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY - halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell2[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell5[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY - halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell5[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell8[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY + halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell8[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell3[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY + halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell3[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell1[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY - halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell1[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell4[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY + halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell4[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + } else { + // 2 + // 1 3 6 5 4 7 8 + if (cell2[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY - halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell2[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell3[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY + halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell3[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell1[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY - halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell1[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell6[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY - halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell6[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell7[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY + halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell7[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell5[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY - halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell5[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell4[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY + halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell4[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell8[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY + halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell8[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + } + } else if (ray.origin.y > cellY) { + if (ray.origin.z > cellZ) { + // 8 + // 5 7 4 1 6 3 2 + + if (cell8[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY + halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell8[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell7[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY + halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell7[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell5[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY - halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell5[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell4[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY + halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell4[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell3[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY + halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell3[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell1[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY - halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell1[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell6[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY - halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell6[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell2[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY - halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell2[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + } else { + // 4 + // 1 3 8 5 7 2 6 + + if (cell4[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY + halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell4[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell8[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY + halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell8[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell3[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY + halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell3[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell1[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY - halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell1[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell7[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY + halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell7[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell5[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + - halfOfCellSize, cellY - halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell5[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell2[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY - halfOfCellSize, cellZ + - halfOfCellSize, halfOfCellSize, cell2[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell6[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + + halfOfCellSize, cellY - halfOfCellSize, cellZ + + halfOfCellSize, halfOfCellSize, cell6[pointer], + ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + } + } else if (ray.origin.z > cellZ) { + // 5 + // 1 6 8 4 2 7 3 + + if (cell5[pointer] != 0) { + rayIntersectionResult = traceCell(cellX - halfOfCellSize, + cellY - halfOfCellSize, cellZ + halfOfCellSize, + halfOfCellSize, cell5[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell1[pointer] != 0) { + rayIntersectionResult = traceCell(cellX - halfOfCellSize, + cellY - halfOfCellSize, cellZ - halfOfCellSize, + halfOfCellSize, cell1[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell6[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + halfOfCellSize, + cellY - halfOfCellSize, cellZ + halfOfCellSize, + halfOfCellSize, cell6[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell8[pointer] != 0) { + rayIntersectionResult = traceCell(cellX - halfOfCellSize, + cellY + halfOfCellSize, cellZ + halfOfCellSize, + halfOfCellSize, cell8[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell4[pointer] != 0) { + rayIntersectionResult = traceCell(cellX - halfOfCellSize, + cellY + halfOfCellSize, cellZ - halfOfCellSize, + halfOfCellSize, cell4[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell7[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + halfOfCellSize, + cellY + halfOfCellSize, cellZ + halfOfCellSize, + halfOfCellSize, cell7[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell2[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + halfOfCellSize, + cellY - halfOfCellSize, cellZ - halfOfCellSize, + halfOfCellSize, cell2[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell3[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + halfOfCellSize, + cellY + halfOfCellSize, cellZ - halfOfCellSize, + halfOfCellSize, cell3[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + } else { + // 1 + // 5 2 4 8 6 3 7 + + if (cell1[pointer] != 0) { + rayIntersectionResult = traceCell(cellX - halfOfCellSize, + cellY - halfOfCellSize, cellZ - halfOfCellSize, + halfOfCellSize, cell1[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell5[pointer] != 0) { + rayIntersectionResult = traceCell(cellX - halfOfCellSize, + cellY - halfOfCellSize, cellZ + halfOfCellSize, + halfOfCellSize, cell5[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell2[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + halfOfCellSize, + cellY - halfOfCellSize, cellZ - halfOfCellSize, + halfOfCellSize, cell2[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell4[pointer] != 0) { + rayIntersectionResult = traceCell(cellX - halfOfCellSize, + cellY + halfOfCellSize, cellZ - halfOfCellSize, + halfOfCellSize, cell4[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell6[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + halfOfCellSize, + cellY - halfOfCellSize, cellZ + halfOfCellSize, + halfOfCellSize, cell6[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell8[pointer] != 0) { + rayIntersectionResult = traceCell(cellX - halfOfCellSize, + cellY + halfOfCellSize, cellZ + halfOfCellSize, + halfOfCellSize, cell8[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + + if (cell3[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + halfOfCellSize, + cellY + halfOfCellSize, cellZ - halfOfCellSize, + halfOfCellSize, cell3[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + if (cell7[pointer] != 0) { + rayIntersectionResult = traceCell(cellX + halfOfCellSize, + cellY + halfOfCellSize, cellZ + halfOfCellSize, + halfOfCellSize, cell7[pointer], ray); + if (rayIntersectionResult >= 0) + return rayIntersectionResult; + } + } + } + return TRACE_NO_HIT; + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/package-info.java new file mode 100755 index 0000000..4509e7e --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/package-info.java @@ -0,0 +1,20 @@ +/** + * Octree-based voxel volume representation and rendering for the Aukio 3D engine. + * + *

This package provides a volumetric data structure based on an octree, which enables + * efficient storage and rendering of voxel data. The octree recursively subdivides 3D space + * into eight octants, achieving significant data compression for sparse or repetitive volumes.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume} - the main octree data structure + * for storing and querying voxel cells
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.octree.IntegerPoint} - integer 3D coordinate used + * for voxel addressing
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.octree.raytracer ray tracing through octree volumes + */ + +package eu.svjatoslav.aukio.e3d.renderer.octree; + diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/CameraView.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/CameraView.java new file mode 100644 index 0000000..fa36212 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/CameraView.java @@ -0,0 +1,55 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.octree.raytracer; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.Camera; +import eu.svjatoslav.aukio.e3d.math.Matrix3x3; + +import static eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.RaytracingCamera.SIZE; + +/** + * Represents camera view. Used to compute direction of rays during ray tracing. + */ +public class CameraView { + + /** + * Camera view coordinates. + */ + Point3D cameraCenter, topLeft, topRight, bottomLeft, bottomRight; + + /** + * Creates a camera view for ray tracing from the given camera and zoom level. + * + * @param camera the camera to create a view for + * @param zoom the zoom level (scales the view frustum) + */ + public CameraView(final Camera camera, final double zoom) { + final float viewAngle = (float) .6; + cameraCenter = new Point3D(); + topLeft = new Point3D(0, 0, SIZE).rotate(-viewAngle, -viewAngle); + topRight = new Point3D(0, 0, SIZE).rotate(viewAngle, -viewAngle); + bottomLeft = new Point3D(0, 0, SIZE).rotate(-viewAngle, viewAngle); + bottomRight = new Point3D(0, 0, SIZE).rotate(viewAngle, viewAngle); + + final Matrix3x3 m = camera.getTransform().getRotation().invert().toMatrix3x3(); + final Point3D temp = new Point3D(); + + temp.clone(topLeft); + m.transform(temp, topLeft); + + temp.clone(topRight); + m.transform(temp, topRight); + + temp.clone(bottomLeft); + m.transform(temp, bottomLeft); + + temp.clone(bottomRight); + m.transform(temp, bottomRight); + + camera.getTransform().getTranslation().clone().divide(zoom).addTo(cameraCenter, topLeft, topRight, bottomLeft, bottomRight); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/LightSource.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/LightSource.java new file mode 100755 index 0000000..dbfcc80 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/LightSource.java @@ -0,0 +1,42 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.octree.raytracer; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; + +/** + * Represents light source. + */ +public class LightSource { + + /** + * Light source color. + */ + public Color color; + /** + * Light source brightness. + */ + public float brightness; + /** + * Light source location. + */ + Point3D location; + + /** + * Creates a light source at the given location with the specified color and brightness. + * + * @param location the position of the light source in world space + * @param color the color of the light + * @param Brightness the brightness multiplier (0.0 = off, 1.0 = full) + */ + public LightSource(final Point3D location, final Color color, + final float Brightness) { + this.location = location; + this.color = color; + brightness = Brightness; + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/Ray.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/Ray.java new file mode 100755 index 0000000..84d7168 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/Ray.java @@ -0,0 +1,71 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.octree.raytracer; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; + +/** + * Represents a ray used for tracing through an {@link eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume}. + * + *

A ray is defined by an {@link #origin} point and a {@link #direction} vector. + * After tracing through the octree, the intersection results are stored in the + * {@link #hitPoint}, {@link #hitCellSize}, and {@link #hitCellX}/{@link #hitCellY}/{@link #hitCellZ} + * fields, which are populated by the octree traversal algorithm.

+ * + * @see RayTracer + * @see eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume#traceCell(int, int, int, int, int, Ray) + */ +public class Ray { + + /** + * The origin point of the ray (the starting position in world space). + */ + public Point3D origin; + + /** + * The direction vector of the ray. Does not need to be normalized; + * the octree traversal handles arbitrary direction magnitudes. + */ + public Point3D direction; + + /** + * The point in world space where the ray intersected an octree cell. + * Set by the octree traversal algorithm after a successful intersection. + */ + public Point3D hitPoint; + + /** + * The size (side length) of the octree cell that was hit. + * A value of 1 indicates a leaf cell at the finest resolution. + */ + public int hitCellSize; + + /** + * The x coordinate of the octree cell that was hit. + */ + public int hitCellX; + + /** + * The y coordinate of the octree cell that was hit. + */ + public int hitCellY; + + /** + * The z coordinate of the octree cell that was hit. + */ + public int hitCellZ; + + /** + * Creates a new ray with the specified origin and direction. + * + * @param origin the starting point of the ray + * @param direction the direction vector of the ray + */ + public Ray(Point3D origin, Point3D direction) { + this.origin = origin; + this.direction = direction; + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RayHit.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RayHit.java new file mode 100755 index 0000000..b3710b7 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RayHit.java @@ -0,0 +1,56 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.octree.raytracer; + +/** + * Records the result of a ray-octree intersection test. + * + *

A {@code RayHit} stores the 3D world-space coordinates where a {@link Ray} + * intersected an octree cell, along with a pointer (index) to the intersected cell + * within the {@link eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume}'s internal + * cell arrays.

+ * + * @see Ray + * @see RayTracer + * @see eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume + */ +public class RayHit { + + /** + * The x coordinate of the intersection point in world space. + */ + float x; + + /** + * The y coordinate of the intersection point in world space. + */ + float y; + + /** + * The z coordinate of the intersection point in world space. + */ + float z; + + /** + * The index (pointer) into the octree's cell arrays identifying the cell that was hit. + */ + int cellPointer; + + /** + * Creates a new ray hit record. + * + * @param x the x coordinate of the intersection point + * @param y the y coordinate of the intersection point + * @param z the z coordinate of the intersection point + * @param cellPointer the index of the intersected cell in the octree's cell arrays + */ + public RayHit(final float x, final float y, final float z, + final int cellPointer) { + this.x = x; + this.y = y; + this.z = z; + this.cellPointer = cellPointer; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RayTracer.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RayTracer.java new file mode 100755 index 0000000..e9a41b0 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RayTracer.java @@ -0,0 +1,411 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.octree.raytracer; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; +import eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture; + +import java.util.Vector; + +/** + * Ray tracing engine for rendering {@link OctreeVolume} scenes onto a {@link Texture}. + * + *

{@code RayTracer} implements {@link Runnable} and is designed to execute as a background + * task. It casts one ray per pixel through the camera's view frustum, tracing each ray + * into the octree volume to find intersections with solid cells. When a hit is found, the + * ray tracer computes per-pixel lighting by casting shadow rays from the hit point toward + * each {@link LightSource} along multiple surface-normal-offset directions (6 directions: + * +X, -X, +Y, -Y, +Z, -Z) to approximate diffuse illumination with soft shadows.

+ * + *

Rendering pipeline

+ *
    + *
  1. The camera's view frustum corners are obtained via {@link RaytracingCamera#getCameraView()}.
  2. + *
  3. For each pixel, a primary ray is constructed from the camera center through the + * interpolated position on the view plane.
  4. + *
  5. The ray is traced through the octree using + * {@link OctreeVolume#traceCell(int, int, int, int, int, Ray)}.
  6. + *
  7. If a solid cell is hit, up to 6 shadow rays are cast toward each light source. + * If no shadow ray is occluded, the light's contribution is accumulated.
  8. + *
  9. The final pixel color is the cell's base color modulated by the accumulated light.
  10. + *
  11. Computed lighting is cached in the octree cell data ({@code cell3}) for reuse.
  12. + *
+ * + *

Progress is reported periodically by invalidating the texture's mipmap cache and + * requesting a repaint on the {@link ViewPanel}, allowing partial results to be displayed + * while rendering continues.

+ * + * @see OctreeVolume + * @see Ray + * @see LightSource + * @see RaytracingCamera + */ +public class RayTracer implements Runnable { + + /** + * Minimum interval in milliseconds between progress updates (texture refresh and repaint). + */ + private static final int PROGRESS_UPDATE_FREQUENCY_MILLIS = 1000; + + /** + * The raytracing camera defining the viewpoint and view frustum for ray generation. + */ + private final RaytracingCamera raytracingCamera; + + /** + * The target texture where rendered pixels are written. + */ + private final Texture texture; + + /** + * The view panel used for triggering display repaints during progressive rendering. + */ + private final ViewPanel viewPanel; + + /** + * The octree volume to be ray-traced. + */ + private final OctreeVolume octreeVolume; + + /** + * The list of light sources used for illumination calculations. + */ + private final Vector lights; + + /** + * Counter tracking the number of light computations performed during the current render pass. + */ + private int computedLights; + + /** + * Creates a new ray tracer for the given scene configuration. + * + * @param texture the texture to render into; its primary bitmap dimensions + * determine the output resolution + * @param octreeVolume the octree volume containing the scene geometry + * @param lights the light sources to use for illumination + * @param raytracingCamera the raytracing camera defining the viewpoint + * @param viewPanel the view panel for triggering progress repaints + */ + public RayTracer(final Texture texture, final OctreeVolume octreeVolume, + final Vector lights, final RaytracingCamera raytracingCamera, + final ViewPanel viewPanel) { + + this.texture = texture; + this.octreeVolume = octreeVolume; + this.lights = lights; + this.raytracingCamera = raytracingCamera; + this.viewPanel = viewPanel; + } + + /** + * Executes the ray tracing render pass. + * + *

Iterates over every pixel of the target texture, constructs a primary ray + * from the camera center through the view plane, traces it into the octree volume, + * and writes the resulting color. The texture is periodically refreshed to show + * progressive results.

+ */ + @Override + public void run() { + computedLights = 0; + + // create camera + + // Camera cam = new Camera(camCenter, upLeft, upRight, downLeft, + // downRight); + + // add camera to the raytracing point + // Main.mainWorld.geometryCollection.addObject(cam); + // Main.mainWorld.compiledGeometry.compileGeometry(Main.mainWorld.geometryCollection); + + final int width = texture.primaryBitmap.width; + final int height = texture.primaryBitmap.height; + + final CameraView cameraView = raytracingCamera.getCameraView(); + + // calculate vertical vectors + final double x1p = cameraView.bottomLeft.x - cameraView.topLeft.x; + final double y1p = cameraView.bottomLeft.y - cameraView.topLeft.y; + final double z1p = cameraView.bottomLeft.z - cameraView.topLeft.z; + + final double x2p = cameraView.bottomRight.x - cameraView.topRight.x; + final double y2p = cameraView.bottomRight.y - cameraView.topRight.y; + final double z2p = cameraView.bottomRight.z - cameraView.topRight.z; + + long nextBitmapUpdate = System.currentTimeMillis() + + PROGRESS_UPDATE_FREQUENCY_MILLIS; + + for (int y = 0; y < height; y++) { + final double cx1 = cameraView.topLeft.x + ((x1p * y) / height); + final double cy1 = cameraView.topLeft.y + ((y1p * y) / height); + final double cz1 = cameraView.topLeft.z + ((z1p * y) / height); + + final double cx2 = cameraView.topRight.x + ((x2p * y) / height); + final double cy2 = cameraView.topRight.y + ((y2p * y) / height); + final double cz2 = cameraView.topRight.z + ((z2p * y) / height); + + // calculate horizontal vector + final double x3p = cx2 - cx1; + final double y3p = cy2 - cy1; + final double z3p = cz2 - cz1; + + for (int x = 0; x < width; x++) { + final double cx3 = cx1 + ((x3p * x) / width); + final double cy3 = cy1 + ((y3p * x) / width); + final double cz3 = cz1 + ((z3p * x) / width); + + final Ray r = new Ray( + new Point3D(cameraView.cameraCenter.x, + cameraView.cameraCenter.y, + cameraView.cameraCenter.z), + new Point3D( + cx3 - cameraView.cameraCenter.x, cy3 + - cameraView.cameraCenter.y, cz3 + - cameraView.cameraCenter.z) + ); + final int c = traceRay(r); + + final Color color = new Color(c); + texture.primaryBitmap.drawPixel(x, y, color); + } + + if (System.currentTimeMillis() > nextBitmapUpdate) { + nextBitmapUpdate = System.currentTimeMillis() + + PROGRESS_UPDATE_FREQUENCY_MILLIS; + texture.resetResampledBitmapCache(); + viewPanel.repaintDuringNextViewUpdate(); + } + } + + texture.resetResampledBitmapCache(); + viewPanel.repaintDuringNextViewUpdate(); + } + + /** + * Traces a single ray into the octree volume and computes the resulting pixel color. + * + *

If the ray intersects a solid cell, the method computes diffuse lighting by + * casting shadow rays from 6 surface-offset positions toward each light source. + * The lighting result is cached in the octree's {@code cell3} array to avoid + * redundant computation for the same cell.

+ * + * @param ray the ray to trace (origin and direction must be set) + * @return the packed RGB color value (0xRRGGBB), or 0 if the ray hits nothing + */ + private int traceRay(final Ray ray) { + + final int intersectingCell = octreeVolume.traceCell(0, 0, 0, + octreeVolume.masterCellSize, 0, ray); + + if (intersectingCell != -1) { + // if lighting not computed, compute it + if (octreeVolume.cell3[intersectingCell] == -1) + // if cell is larger than 1 + if (ray.hitCellSize > 1) { + // break it up + octreeVolume.breakSolidCell(intersectingCell); + return traceRay(ray); + } else { + computedLights++; + float red = 30, green = 30, blue = 30; + + for (final LightSource l : lights) { + final double xDist = (l.location.x - ray.hitCellX); + final double yDist = (l.location.y - ray.hitCellY); + final double zDist = (l.location.z - ray.hitCellZ); + + double newRed = 0, newGreen = 0, newBlue = 0; + double tempRed, tempGreen, tempBlue; + + double distance = Math.sqrt((xDist * xDist) + + (yDist * yDist) + (zDist * zDist)); + distance = (distance / 3) + 1; + + final Ray r1 = new Ray( + new Point3D( + ray.hitCellX, + ray.hitCellY - (float) 1.5, + ray.hitCellZ), + + new Point3D((float) l.location.x - (float) ray.hitCellX, l.location.y + - (ray.hitCellY - (float) 1.5), (float) l.location.z + - (float) ray.hitCellZ) + ); + + final int rt1 = octreeVolume.traceCell(0, 0, 0, + octreeVolume.masterCellSize, 0, r1); + + if (rt1 == -1) { + newRed = (l.color.r * l.brightness) / distance; + newGreen = (l.color.g * l.brightness) / distance; + newBlue = (l.color.b * l.brightness) / distance; + } + + final Ray r2 = new Ray( + new Point3D( + ray.hitCellX - (float) 1.5, + ray.hitCellY, ray.hitCellZ), + + new Point3D( + l.location.x - (ray.hitCellX - (float) 1.5), (float) l.location.y + - (float) ray.hitCellY, (float) l.location.z + - (float) ray.hitCellZ) + ); + + final int rt2 = octreeVolume.traceCell(0, 0, 0, + octreeVolume.masterCellSize, 0, r2); + + if (rt2 == -1) { + tempRed = (l.color.r * l.brightness) / distance; + tempGreen = (l.color.g * l.brightness) / distance; + tempBlue = (l.color.b * l.brightness) / distance; + + if (tempRed > newRed) + newRed = tempRed; + if (tempGreen > newGreen) + newGreen = tempGreen; + if (tempBlue > newBlue) + newBlue = tempBlue; + } + + final Ray r3 = new Ray( + new Point3D( + ray.hitCellX, ray.hitCellY, + ray.hitCellZ - (float) 1.5), + new Point3D( + (float) l.location.x - (float) ray.hitCellX, (float) l.location.y + - (float) ray.hitCellY, l.location.z + - (ray.hitCellZ - (float) 1.5)) + ); + + final int rt3 = octreeVolume.traceCell(0, 0, 0, + octreeVolume.masterCellSize, 0, r3); + + if (rt3 == -1) { + tempRed = (l.color.r * l.brightness) / distance; + tempGreen = (l.color.g * l.brightness) / distance; + tempBlue = (l.color.b * l.brightness) / distance; + if (tempRed > newRed) + newRed = tempRed; + if (tempGreen > newGreen) + newGreen = tempGreen; + if (tempBlue > newBlue) + newBlue = tempBlue; + } + + final Ray r4 = new Ray( + new Point3D( + ray.hitCellX, + ray.hitCellY + (float) 1.5, + ray.hitCellZ), + + new Point3D( + (float) l.location.x - (float) ray.hitCellX, l.location.y + - (ray.hitCellY + (float) 1.5), (float) l.location.z + - (float) ray.hitCellZ) + ); + + final int rt4 = octreeVolume.traceCell(0, 0, 0, + octreeVolume.masterCellSize, 0, r4); + + if (rt4 == -1) { + tempRed = (l.color.r * l.brightness) / distance; + tempGreen = (l.color.g * l.brightness) / distance; + tempBlue = (l.color.b * l.brightness) / distance; + if (tempRed > newRed) + newRed = tempRed; + if (tempGreen > newGreen) + newGreen = tempGreen; + if (tempBlue > newBlue) + newBlue = tempBlue; + } + + final Ray r5 = new Ray( + new Point3D( + ray.hitCellX + (float) 1.5, + ray.hitCellY, ray.hitCellZ), + + new Point3D( + l.location.x - (ray.hitCellX + (float) 1.5), (float) l.location.y + - (float) ray.hitCellY, (float) l.location.z + - (float) ray.hitCellZ) + ); + + final int rt5 = octreeVolume.traceCell(0, 0, 0, + octreeVolume.masterCellSize, 0, r5); + + if (rt5 == -1) { + tempRed = (l.color.r * l.brightness) / distance; + tempGreen = (l.color.g * l.brightness) / distance; + tempBlue = (l.color.b * l.brightness) / distance; + if (tempRed > newRed) + newRed = tempRed; + if (tempGreen > newGreen) + newGreen = tempGreen; + if (tempBlue > newBlue) + newBlue = tempBlue; + } + + final Ray r6 = new Ray( + new Point3D( + ray.hitCellX, ray.hitCellY, + ray.hitCellZ + (float) 1.5), + + new Point3D( + + (float) l.location.x - (float) ray.hitCellX, (float) l.location.y + - (float) ray.hitCellY, l.location.z + - (ray.hitCellZ + (float) 1.5))); + + final int rt6 = octreeVolume.traceCell(0, 0, 0, + octreeVolume.masterCellSize, 0, r6); + + if (rt6 == -1) { + tempRed = (l.color.r * l.brightness) / distance; + tempGreen = (l.color.g * l.brightness) / distance; + tempBlue = (l.color.b * l.brightness) / distance; + if (tempRed > newRed) + newRed = tempRed; + if (tempGreen > newGreen) + newGreen = tempGreen; + if (tempBlue > newBlue) + newBlue = tempBlue; + } + red += newRed; + green += newGreen; + blue += newBlue; + + } + + final int cellColor = octreeVolume.cell2[intersectingCell]; + + red = (red * ((cellColor & 0xFF0000) >> 16)) / 255; + green = (green * ((cellColor & 0xFF00) >> 8)) / 255; + blue = (blue * (cellColor & 0xFF)) / 255; + + if (red > 255) + red = 255; + if (green > 255) + green = 255; + if (blue > 255) + blue = 255; + + octreeVolume.cell3[intersectingCell] = (((int) red) << 16) + + (((int) green) << 8) + ((int) blue); + + } + if (octreeVolume.cell3[intersectingCell] == 0) + return octreeVolume.cell2[intersectingCell]; + return octreeVolume.cell3[intersectingCell]; + } + + // return (200 << 16) + (200 << 8) + 255; + return 0; + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RaytracingCamera.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RaytracingCamera.java new file mode 100755 index 0000000..ed67441 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RaytracingCamera.java @@ -0,0 +1,136 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.octree.raytracer; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.Camera; +import eu.svjatoslav.aukio.e3d.math.Matrix3x3; +import eu.svjatoslav.aukio.e3d.math.Transform; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.TexturedRectangle; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.net.URL; + +/** + * Raytracing camera that renders a scene to a texture. + * It is represented on the scene as a textured rectangle showing the raytraced view. + */ +public class RaytracingCamera extends TexturedRectangle { + + /** Size of the camera view in world units. */ + public static final int SIZE = 100; + /** Size of the rendered image in pixels. */ + public static final int IMAGE_SIZE = 500; + private final CameraView cameraView; + + /** + * Creates a raytracing camera at the specified camera position. + * + * @param camera the camera to use for the view + * @param zoom the zoom level + */ + public RaytracingCamera(final Camera camera, final double zoom) { + super(new Transform(camera.getTransform().getTranslation().clone())); + cameraView = new CameraView(camera, zoom); + + computeCameraCoordinates(camera); + + addWaitNotification(getTexture()); + } + + private void addWaitNotification(final Texture texture) { + // add hourglass icon + try { + final BufferedImage sprite = getSprite("eu/svjatoslav/aukio/e3d/examples/hourglass.png"); + texture.graphics.drawImage(sprite, IMAGE_SIZE / 2, + (IMAGE_SIZE / 2) - 30, null); + } catch (final Exception ignored) { + } + + // add "Please wait..." message + texture.graphics.setColor(java.awt.Color.WHITE); + texture.graphics.setFont(new Font("Monospaced", Font.PLAIN, 10)); + texture.graphics.drawString("Please wait...", (IMAGE_SIZE / 2) - 20, + (IMAGE_SIZE / 2) + 30); + } + + private void computeCameraCoordinates(final Camera camera) { + initialize(SIZE, SIZE, IMAGE_SIZE, IMAGE_SIZE, 3); + + Point3D cameraCenter = new Point3D(); + + topLeft.setValues(cameraCenter.x, cameraCenter.y, cameraCenter.z + SIZE); + topRight.clone(topLeft); + bottomLeft.clone(topLeft); + bottomRight.clone(topLeft); + + final float viewAngle = (float) .6; + + topLeft.rotate(cameraCenter, -viewAngle, -viewAngle); + topRight.rotate(cameraCenter, viewAngle, -viewAngle); + bottomLeft.rotate(cameraCenter, -viewAngle, viewAngle); + bottomRight.rotate(cameraCenter, viewAngle, viewAngle); + + final Matrix3x3 m = camera.getTransform().getRotation().invert().toMatrix3x3(); + final Point3D temp = new Point3D(); + + temp.clone(topLeft); + temp.subtract(cameraCenter); + m.transform(temp, topLeft); + topLeft.add(cameraCenter); + + temp.clone(topRight); + temp.subtract(cameraCenter); + m.transform(temp, topRight); + topRight.add(cameraCenter); + + temp.clone(bottomLeft); + temp.subtract(cameraCenter); + m.transform(temp, bottomLeft); + bottomLeft.add(cameraCenter); + + temp.clone(bottomRight); + temp.subtract(cameraCenter); + m.transform(temp, bottomRight); + bottomRight.add(cameraCenter); + + final Color cameraColor = new Color(255, 255, 0, 255); + final LineAppearance appearance = new LineAppearance(2, cameraColor); + + addShape(appearance.getLine(topLeft, topRight)); + addShape(appearance.getLine(bottomLeft, bottomRight)); + addShape(appearance.getLine(topLeft, bottomLeft)); + addShape(appearance.getLine(topRight, bottomRight)); + + } + + /** + * Returns the camera view used for ray tracing. + * + * @return the camera view + */ + public CameraView getCameraView() { + return cameraView; + } + + /** + * Loads a sprite image from the classpath. + * + * @param ref the resource path + * @return the loaded image + * @throws IOException if the image cannot be loaded + */ + public BufferedImage getSprite(final String ref) throws IOException { + final URL url = this.getClass().getClassLoader().getResource(ref); + return ImageIO.read(url); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/package-info.java new file mode 100755 index 0000000..e49132c --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/package-info.java @@ -0,0 +1,21 @@ +/** + * Ray tracer for rendering voxel data stored in an octree structure. + * + *

This package implements a ray tracing renderer that casts rays through an + * {@link eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume} to produce rendered images + * of volumetric data. The ray tracer traverses the octree hierarchy for efficient + * intersection testing, skipping empty regions of space.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.RayTracer} - main ray tracing engine
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.RaytracingCamera} - camera configuration for ray generation
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.Ray} - represents a single ray cast through the volume
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.LightSource} - defines a light source for shading
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume the voxel data structure + */ + +package eu.svjatoslav.aukio.e3d.renderer.octree.raytracer; + diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/package-info.java new file mode 100755 index 0000000..2c18a77 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/package-info.java @@ -0,0 +1,11 @@ +/** + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + *

+ * + * Various 3D renderers utilizing different rendering approaches. + * + */ + +package eu.svjatoslav.aukio.e3d.renderer; + diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/Color.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/Color.java new file mode 100644 index 0000000..93e80ec --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/Color.java @@ -0,0 +1,353 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster; + +/** + * RGBA color representation for the Aukio 3D engine. + * + *

This is the engine's own color class (not {@link java.awt.Color}). All color values + * use integer components in the range 0-255. The class provides predefined constants + * for common colors and several constructors for creating colors from different formats.

+ * + *

Mutability: Color fields are mutable to enable reuse during rendering + * (e.g., lighting calculations). This avoids allocating new Color instances per polygon.

+ * + *

Usage examples:

+ *
{@code
+ * // Use predefined color constants
+ * Color red = Color.RED;
+ * Color semiTransparent = Color.hex("FF000080");
+ *
+ * // Create from hex string (recommended)
+ * Color hex6 = Color.hex("FF8800");     // RGB, fully opaque
+ * Color hex8 = Color.hex("FF880080");   // RGBA with alpha
+ * Color hex3 = Color.hex("F80");        // Short RGB format
+ *
+ * // Create from integer RGBA components (0-255)
+ * Color custom = new Color(100, 200, 50, 255);
+ *
+ * // Create from packed RGB integer
+ * Color packed = new Color(0xFF8800);
+ *
+ * // Modify existing color (avoids allocation)
+ * color.set(255, 128, 0, 255);
+ * }
+ * + *

Important: Always use this class instead of {@link java.awt.Color} when + * working with the Aukio 3D engine's rendering pipeline.

+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line + */ +public final class Color { + + /** + * Fully opaque red (255, 0, 0). + */ + public static final Color RED = new Color(255, 0, 0, 255); + /** + * Fully opaque green (0, 255, 0). + */ + public static final Color GREEN = new Color(0, 255, 0, 255); + /** + * Fully opaque blue (0, 0, 255). + */ + public static final Color BLUE = new Color(0, 0, 255, 255); + /** + * Fully opaque yellow (255, 255, 0). + */ + public static final Color YELLOW = new Color(255, 255, 0, 255); + /** + * Fully opaque cyan (0, 255, 255). + */ + public static final Color CYAN = new Color(0, 255, 255, 255); + /** + * Fully opaque magenta/purple (255, 0, 255). + */ + public static final Color MAGENTA = new Color(255, 0, 255, 255); + /** + * Fully opaque white (255, 255, 255). + */ + public static final Color WHITE = new Color(255, 255, 255, 255); + /** + * Fully opaque black (0, 0, 0). + */ + public static final Color BLACK = new Color(0, 0, 0, 255); + /** + * Fully opaque purple/magenta (255, 0, 255). + */ + public static final Color PURPLE = new Color(255, 0, 255, 255); + /** + * Fully transparent (alpha = 0). + */ + public static final Color TRANSPARENT = new Color(0, 0, 0, 0); + /** + * Red component. 0-255. + */ + public int r; + /** + * Green component. 0-255. + */ + public int g; + /** + * Blue component. 0-255. + */ + public int b; + /** + * Alpha component. + * 0 - transparent. + * 255 - opaque. + */ + public int a; + private java.awt.Color cachedAwtColor; + + /** + * Creates a black, fully opaque color (0, 0, 0, 255). + */ + public Color() { + this.r = 0; + this.g = 0; + this.b = 0; + this.a = 255; + } + + /** + * Creates a copy of the given color. + * + * @param parentColor the color to copy + */ + public Color(final Color parentColor) { + r = parentColor.r; + g = parentColor.g; + b = parentColor.b; + a = parentColor.a; + } + + /** + * Creates a color from floating-point RGBA components in the range 0.0 to 1.0. + * Values are internally converted to 0-255 integer range and clamped. + * + * @param r red component (0.0 = none, 1.0 = full) + * @param g green component (0.0 = none, 1.0 = full) + * @param b blue component (0.0 = none, 1.0 = full) + * @param a alpha component (0.0 = transparent, 1.0 = opaque) + */ + public Color(final double r, final double g, final double b, final double a) { + this.r = clamp((int) (r * 255d)); + this.g = clamp((int) (g * 255d)); + this.b = clamp((int) (b * 255d)); + this.a = clamp((int) (a * 255d)); + } + + /** + * Creates a color from a hexadecimal string. + * + * @param colorHexCode color code in hex format. + * Supported formats are: + *
+     *                                         RGB
+     *                                         RGBA
+     *                                         RRGGBB
+     *                                         RRGGBBAA
+     *                                         
+ */ + public Color(String colorHexCode) { + switch (colorHexCode.length()) { + case 3: + r = parseHexSegment(colorHexCode, 0, 1) * 16; + g = parseHexSegment(colorHexCode, 1, 1) * 16; + b = parseHexSegment(colorHexCode, 2, 1) * 16; + a = 255; + return; + + case 4: + r = parseHexSegment(colorHexCode, 0, 1) * 16; + g = parseHexSegment(colorHexCode, 1, 1) * 16; + b = parseHexSegment(colorHexCode, 2, 1) * 16; + a = parseHexSegment(colorHexCode, 3, 1) * 16; + return; + + case 6: + r = parseHexSegment(colorHexCode, 0, 2); + g = parseHexSegment(colorHexCode, 2, 2); + b = parseHexSegment(colorHexCode, 4, 2); + a = 255; + return; + + case 8: + r = parseHexSegment(colorHexCode, 0, 2); + g = parseHexSegment(colorHexCode, 2, 2); + b = parseHexSegment(colorHexCode, 4, 2); + a = parseHexSegment(colorHexCode, 6, 2); + return; + default: + throw new IllegalArgumentException("Unsupported color code: " + colorHexCode); + } + } + + /** + * Creates a fully opaque color from a packed RGB integer. + * + *

The integer is interpreted as {@code 0xRRGGBB}, where the upper 8 bits + * are the red channel, the middle 8 bits are green, and the lower 8 bits are blue.

+ * + * @param rgb packed RGB value (e.g. {@code 0xFF8800} for orange) + */ + public Color(final int rgb) { + r = (rgb & 0xFF0000) >> 16; + g = (rgb & 0xFF00) >> 8; + b = rgb & 0xFF; + a = 255; + } + + /** + * Creates a fully opaque color from RGB integer components (0-255). + * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + */ + public Color(final int r, final int g, final int b) { + this(r, g, b, 255); + } + + /** + * Creates a color from RGBA integer components (0-255). + * Values outside 0-255 are clamped. + * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @param a alpha component (0 = transparent, 255 = opaque) + */ + public Color(final int r, final int g, final int b, final int a) { + this.r = clamp(r); + this.g = clamp(g); + this.b = clamp(b); + this.a = clamp(a); + } + + /** + * Creates a color from a hexadecimal string. + * + *

Supported formats:

+ *
    + *
  • {@code RGB} - 3 hex digits, fully opaque
  • + *
  • {@code RGBA} - 4 hex digits
  • + *
  • {@code RRGGBB} - 6 hex digits, fully opaque
  • + *
  • {@code RRGGBBAA} - 8 hex digits
  • + *
+ * + * @param hex hex color code + * @return a new Color instance + */ + public static Color hex(final String hex) { + return new Color(hex); + } + + /** + * Clamps a value to the valid color component range (0-255). + * + * @param value the value to clamp + * @return the clamped value + */ + public static int clamp(final int value) { + if (value < 0) return 0; + if (value > 255) return 255; + return value; + } + + private int parseHexSegment(String hexString, int start, int length) { + return Integer.parseInt(hexString.substring(start, start + length), 16); + } + + /** + * Returns {@code true} if this color is fully transparent (alpha = 0). + * + * @return {@code true} if the alpha component is zero + */ + public boolean isTransparent() { + return a == 0; + } + + /** + * Sets all color components at once. + * + *

Values outside 0-255 are clamped. This method invalidates any cached + * AWT color, so the next call to {@link #toAwtColor()} will create a new one.

+ * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @param a alpha component (0-255) + * @return this Color for chaining + */ + public Color set(final int r, final int g, final int b, final int a) { + this.r = clamp(r); + this.g = clamp(g); + this.b = clamp(b); + this.a = clamp(a); + cachedAwtColor = null; + return this; + } + + /** + * Copies values from another color. + * + * @param other the color to copy from + * @return this Color for chaining + */ + public Color set(final Color other) { + this.r = other.r; + this.g = other.g; + this.b = other.b; + this.a = other.a; + cachedAwtColor = null; + return this; + } + + /** + * Converts this color to a {@link java.awt.Color} instance for use with + * Java AWT/Swing graphics APIs. + * + * @return the equivalent {@link java.awt.Color} + */ + public java.awt.Color toAwtColor() { + if (cachedAwtColor == null) + cachedAwtColor = new java.awt.Color(r, g, b, a); + return cachedAwtColor; + } + + /** + * Converts this color to a packed ARGB integer as used by {@link java.awt.Color#getRGB()}. + * + * @return packed ARGB integer representation + */ + public int toInt() { + return (a << 24) | (r << 16) | (g << 8) | b; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Color color = (Color) o; + + if (r != color.r) return false; + if (g != color.g) return false; + if (b != color.b) return false; + return a == color.a; + } + + @Override + public int hashCode() { + int result = r; + result = 31 * result + g; + result = 31 * result + b; + result = 31 * result + a; + return result; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ParallelTransformCoordinator.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ParallelTransformCoordinator.java new file mode 100644 index 0000000..2a48f08 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ParallelTransformCoordinator.java @@ -0,0 +1,204 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster; + +import java.util.Queue; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Coordinates the non-blocking parallel transform fork for one frame. + * + *

Every composite whose render list exceeds the parallel threshold (at + * ANY nesting level, not just the root) splits its children into chunks and + * submits chunk tasks here instead of transforming them serially. Chunk + * tasks never block on other tasks, so a fixed-size pool cannot starve; + * only the orchestrating render thread waits, in {@link #drainAndMergeInto}. + * This is what makes recursive decomposition safe: a nested heavy composite + * reached inside a chunk task forks its own children into the same queue + * and returns immediately.

+ * + *

Merge order is irrelevant: the depth sort that follows the transform + * phase is deterministic on (Z, shapeId).

+ * + *

Lifecycle: created by {@code ShapeCollection.transformShapes()} when a + * transform executor is available, published on the rendering context for + * the duration of the root transform, drained and discarded before the + * sort phase.

+ */ +public final class ParallelTransformCoordinator { + + private final ExecutorService executor; + private final Queue> futures = new ConcurrentLinkedQueue<>(); + private final AtomicInteger submittedTaskCount = new AtomicInteger(); + + /** + * Shared pools of chunk-task scratch objects. Coordinators are + * created per render pass, so the pools are static — otherwise + * reuse would never survive the next pass. A pooled aggregator's + * queue list keeps its capacity (reset() does not shrink), and a + * pooled TransformStack keeps its fixed arrays, so steady-state + * frames allocate neither the 9.6 KB stack per chunk nor the + * doubling-copy chain of every chunk's queue. + */ + private static final Queue + STACK_POOL = new ConcurrentLinkedQueue<>(); + private static final Queue + AGGREGATOR_POOL = new ConcurrentLinkedQueue<>(); + + /** + * Borrows a pooled transform stack preloaded with {@code source}'s + * composed state. Must be returned with {@link #returnStack} when + * the chunk task finishes. + * + * @param source stack to copy into the borrowed instance + * @return a stack, possibly reused from a previous frame + */ + public eu.svjatoslav.aukio.e3d.math.TransformStack borrowStack( + final eu.svjatoslav.aukio.e3d.math.TransformStack source) { + eu.svjatoslav.aukio.e3d.math.TransformStack stack = STACK_POOL.poll(); + if (stack == null) + stack = new eu.svjatoslav.aukio.e3d.math.TransformStack(); + stack.loadFrom(source); + return stack; + } + + /** + * Returns a borrowed stack to the pool. The caller must not touch + * it afterwards. + * + * @param stack the borrowed stack + */ + public void returnStack( + final eu.svjatoslav.aukio.e3d.math.TransformStack stack) { + STACK_POOL.offer(stack); + } + + /** + * Borrows a pooled chunk aggregator (reset, but with its queue + * capacity intact from previous frames). + * + * @return an aggregator, possibly reused from a previous frame + */ + public RenderAggregator borrowAggregator() { + final RenderAggregator aggregator = AGGREGATOR_POOL.poll(); + return aggregator != null ? aggregator : new RenderAggregator(); + } + + /** + * Frame parity captured at construction, stamped onto recorded + * timeline intervals so overlapping frames keep distinct colors. + */ + private final int traceParity = eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.frameParity(); + + /** + * Frame-wide cap on chunk tasks. Deep hierarchies of heavy composites + * would otherwise fork geometrically (each fork targets cores*4 tasks); + * past the cap, composites transform serially inline. The cap is + * generous enough that realistic scenes never hit it. + */ + private final int maxTasks = Runtime.getRuntime().availableProcessors() * 64; + + public ParallelTransformCoordinator(final ExecutorService executor) { + this.executor = executor; + } + + /** + * Reserves budget for {@code count} chunk tasks. Returns false when the + * frame-wide cap would be exceeded; the caller must then transform + * serially instead of forking. + * + * @param count number of chunk tasks the caller intends to submit + * @return true when the reservation was granted + */ + public boolean tryReserveTasks(final int count) { + if (submittedTaskCount.addAndGet(count) > maxTasks) { + submittedTaskCount.addAndGet(-count); + return false; + } + return true; + } + + /** + * Submits one chunk task. May be called from the orchestrating thread + * (root fork) or from inside a running chunk task (nested fork). + * Callers must have reserved budget via {@link #tryReserveTasks(int)}. + * + * @param task transforms a chunk of children into a private aggregator + */ + public void submit(final Callable task) { + if (eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.isEnabled()) { + final int parity = traceParity; + futures.add(executor.submit(() -> { + final long t0 = System.nanoTime(); + try { + return task.call(); + } finally { + eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.record( + eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.KIND_TRANSFORM + parity, + t0, System.nanoTime()); + } + })); + return; + } + futures.add(executor.submit(task)); + } + + /** + * Total chunk tasks submitted to this coordinator, across all nesting + * levels. Diagnostics for tests and profiling. + * + * @return number of submitted chunk tasks + */ + public int getSubmittedTaskCount() { + return submittedTaskCount.get(); + } + + /** + * Waits for all submitted chunk tasks, including tasks submitted by + * other tasks (nested forks), and merges their aggregators into + * {@code target}. Must be called from the single orchestrating render + * thread after the root composite's {@code transform()} returns. + * + *

Termination is guaranteed: a task enqueues all its own submissions + * before completing, so once every future polled so far has completed + * and the queue is empty, no further submissions can arrive.

+ * + * @param target the root aggregator to merge results into + */ + public void drainAndMergeInto(final RenderAggregator target) { + final boolean trace = eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.isEnabled(); + final java.util.List parts = new java.util.ArrayList<>(); + Future future; + while ((future = futures.poll()) != null) { + try { + final long t0 = trace ? System.nanoTime() : 0; + parts.add(future.get()); + if (trace) { + eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.record( + eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.KIND_AWAIT, + t0, System.nanoTime()); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during parallel transform", e); + } catch (final ExecutionException e) { + throw new RuntimeException("Parallel transform task failed", e.getCause()); + } + } + target.mergeAllParallel(parts, executor); + // Return chunk aggregators to the pool: reset() keeps their + // queue capacity, so next frame's chunks start at steady-state + // size instead of re-growing by doubling copies. + for (final RenderAggregator part : parts) { + part.reset(); + AGGREGATOR_POOL.offer(part); + } + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RadixLongSort.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RadixLongSort.java new file mode 100644 index 0000000..0c8d150 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RadixLongSort.java @@ -0,0 +1,99 @@ +package eu.svjatoslav.aukio.e3d.renderer.raster; + +/** + * Stable LSD radix sort over parallel (key, index) arrays, plus the + * Z-to-sortable-key mapping used by {@link RenderAggregator}'s fast sort + * path. Byte-wise LSD passes make the sort stable, so equal keys keep + * their original relative order; the caller resolves remaining ties + * explicitly (by shapeId) afterwards. + * + *

Keys are interpreted as unsigned 64-bit values (digit extraction is + * unsigned); {@link #zSortKey(double)} returns keys pre-biased so that + * unsigned key order equals the comparator's paint order.

+ */ +final class RadixLongSort { + + private RadixLongSort() { + } + + /** + * Maps a camera-space Z (already depth-bias adjusted) to a 64-bit key + * whose unsigned order is DESCENDING in z — i.e. the painter's + * back-to-front order. Matches the comparator's contract: + * + *
    + *
  • {@code z1 > z2} (double semantics) iff key1 unsigned< key2;
  • + *
  • {@code -0.0} is normalized to {@code +0.0} — the comparator + * compares with {@code <}/{@code >}, which treats them equal, + * so they must share one key;
  • + *
  • NaN (never expected: queued shapes passed the near-plane + * cull) maps to one canonical key, making all-NaN ties resolve + * deterministically by shapeId.
  • + *
+ */ + static long zSortKey(final double zIn) { + double z = zIn; + if (z == 0.0d) + z = 0.0d; // normalize -0.0 -> +0.0 + long bits = Double.doubleToRawLongBits(z); + // IEEE 754 -> monotone signed long (negatives flip magnitude bits) + bits ^= (bits >> 63) & 0x7fffffffffffffffL; + // descending order + unsigned-digit friendliness + return ~bits ^ Long.MIN_VALUE; + } + + /** + * Ascending-Z variant for z-buffer mode: the opaque pass paints + * front-to-back for early-z rejection (correctness comes from the + * depth test, so order is purely a performance heuristic). Ties map + * to equal keys exactly like {@link #zSortKey}. + */ + static long zSortKeyAscending(final double zIn) { + double z = zIn; + if (z == 0.0d) + z = 0.0d; + long bits = Double.doubleToRawLongBits(z); + bits ^= (bits >> 63) & 0x7fffffffffffffffL; + return bits ^ Long.MIN_VALUE; + } + + /** + * Stable LSD radix sort of pairs {@code (keys[i], idx[i])}, ascending + * by unsigned key interpretation. Eight 8-bit counting passes; the + * even pass count leaves the result back in {@code keys}/{@code idx} + * (no final copy). Scratch arrays must be at least n long. + */ + static void sortPairs(final long[] keys, final int[] idx, final int n, + final long[] keyTmp, final int[] idxTmp) { + long[] srcK = keys; + long[] dstK = keyTmp; + int[] srcI = idx; + int[] dstI = idxTmp; + final int[] count = new int[256]; + for (int shift = 0; shift < 64; shift += 8) { + java.util.Arrays.fill(count, 0); + for (int i = 0; i < n; i++) + count[(int) ((srcK[i] >>> shift) & 0xFF)]++; + int sum = 0; + for (int d = 0; d < 256; d++) { + final int c = count[d]; + count[d] = sum; + sum += c; + } + for (int i = 0; i < n; i++) { + final int d = (int) ((srcK[i] >>> shift) & 0xFF); + final int p = count[d]++; + dstK[p] = srcK[i]; + dstI[p] = srcI[i]; + } + final long[] tk = srcK; + srcK = dstK; + dstK = tk; + final int[] ti = srcI; + srcI = dstI; + dstI = ti; + } + // 8 passes: after the final swap the sorted pairs are back in the + // caller's keys/idx arrays. + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RenderAggregator.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RenderAggregator.java new file mode 100644 index 0000000..bb28b67 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RenderAggregator.java @@ -0,0 +1,861 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster; + +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; + +/** + * Collects transformed shapes during a render frame and paints them in depth-sorted order. + * + *

The {@code RenderAggregator} implements the painter's algorithm: shapes are sorted + * from back to front (highest Z-depth first) and then painted sequentially. This ensures + * that closer shapes correctly occlude those behind them.

+ * + *

When two shapes have the same Z-depth, their unique {@link AbstractCoordinateShape#shapeId} + * is used as a tiebreaker to guarantee deterministic rendering order.

+ * + *

This class is used internally by {@link ShapeCollection} during the render pipeline. + * You typically do not need to interact with it directly.

+ * + * @see ShapeCollection#paintShapes(RenderingContext) + * @see AbstractCoordinateShape#getZ(int) + */ +public class RenderAggregator { + + /** + * Creates a new render aggregator. + */ + public RenderAggregator() { + this(0); + } + + /** + * Creates an aggregator bound to a projection buffer slot. Sorting and + * tile binning read the shapes' screen state for this slot, so the + * double-buffered pipeline can fill one slot's aggregator while the + * other slot's aggregator is still being painted. + * + * @param slot the buffer slot (0 or 1) this aggregator serves + */ + public RenderAggregator(final int slot) { + this.slot = slot; + } + + /** Buffer slot whose screen state this aggregator sorts and bins by. */ + private final int slot; + + private final ArrayList shapes = new ArrayList<>(); + private final ShapesZIndexComparator comparator = new ShapesZIndexComparator(); + private boolean sorted = false; + + /** + * The sorted queue as a flat array, produced by {@link #sort()}. + * Paint and binning iterate this instead of the list: sorting works + * on the array in place, so the queue is copied exactly once. + * The backing array is REUSED across frames (grow-only); only the + * first {@link #sortedCount} entries are valid. + */ + private AbstractCoordinateShape[] sortedArray; + /** Valid entries of {@link #sortedArray} (it is oversized by reuse). */ + private int sortedCount; + + /** + * Reusable flat queue storage (grow-only). {@link #sort()} fills it + * either from the shape list or from {@link #mergeAllParallel}. + */ + private AbstractCoordinateShape[] queueArray; + /** Valid entries of {@link #queueArray} after a merge. */ + private int pendingMergeCount; + + /** Reusable merge-sort scratch (grow-only), see parallelMergeSort. */ + private AbstractCoordinateShape[] sortScratch; + + /** Radix-sort scratch (grow-only): keys, indices, and their swap + * buffers, plus the tie-run pack array. See tryRadixSort. */ + private long[] radixKeys; + private long[] radixKeysTmp; + private long[] tiePack; + private int[] radixIdx; + private int[] radixIdxTmp; + + private static long[] ensureCapacity(final long[] array, final int capacity) { + return (array != null && array.length >= capacity) + ? array : new long[capacity]; + } + + private static int[] ensureCapacity(final int[] array, final int capacity) { + return (array != null && array.length >= capacity) + ? array : new int[capacity]; + } + + /** Grow-only capacity helper: returns {@code array} or a bigger one. */ + private static AbstractCoordinateShape[] ensureCapacity( + final AbstractCoordinateShape[] array, final int capacity) { + return array != null && array.length >= capacity + ? array : new AbstractCoordinateShape[capacity]; + } + + /** + * Sorts all queued shapes by Z-depth (back to front) and paints them. + * + * @param renderBuffer the rendering context to paint shapes into + */ + public void paint(final RenderingContext renderBuffer) { + ensureSorted(); + paintSorted(renderBuffer); + } + + /** + * Above this many queued shapes, {@link #sort()} uses a parallel sort + * on the fork/join common pool instead of a single-threaded sort. + */ + private static final int PARALLEL_SORT_THRESHOLD = 8192; + + /** + * Sorts all queued shapes by Z-depth (back to front). + * Must be called after all shapes are queued and before paintSorted. + * Uses a parallel sort for large queues. + */ + public void sort() { + sort(null); + } + + /** + * Sorts the queue by (Z, shapeId), using an instrumented parallel + * merge sort on the given executor for large queues. Unlike + * {@code Arrays.parallelSort}, every subtask is recorded on the + * thread-activity timeline, so the sort does not appear as phantom + * idle time on the worker rows. Deterministic: (Z, shapeId) is a + * total order, so any merge schedule yields the same result. + * + * @param executor executor for parallel sorting, or null for serial + */ + public void sort(final ExecutorService executor) { + if (!sorted) { + comparator.sortSlot = slot; + if (pendingMergeCount > 0) { + // Merge already produced a flat array: sort it in place, + // no list copy at all + sortedArray = queueArray; + sortedCount = pendingMergeCount; + pendingMergeCount = 0; + } else { + // toArray(target) reuses the target when it fits: + // zero-allocation queue copy at steady state + sortedCount = shapes.size(); + sortedArray = shapes.toArray( + ensureCapacity(queueArray, sortedCount)); + queueArray = sortedArray; + } + if (executor != null && sortedCount >= PARALLEL_SORT_THRESHOLD) { + if (!tryRadixSort(sortedArray, sortedCount)) + parallelMergeSort(sortedArray, sortedCount, comparator, + executor); + } else if (sortedCount >= PARALLEL_SORT_THRESHOLD) { + Arrays.parallelSort(sortedArray, 0, sortedCount, + comparator); + } else { + Arrays.sort(sortedArray, 0, sortedCount, comparator); + } + sorted = true; + } + } + + /** + * Fast sort: maps Z-depth to unsigned-ordered long keys, stable-sorts + * (key, queueIndex) pairs with an LSD radix sort, then fixes equal-key + * runs to ascending shapeId — reproducing the comparator's total order + * (Z descending, shapeId ascending) exactly, without a single + * comparator call. Sequential memory throughout: key build and the + * final permute stream the queue array, the radix passes stream + * long/int arrays. + * + * @return true when the radix path sorted the queue + */ + private boolean tryRadixSort(final AbstractCoordinateShape[] array, + final int length) { + radixKeys = ensureCapacity(radixKeys, length); + radixKeysTmp = ensureCapacity(radixKeysTmp, length); + radixIdx = ensureCapacity(radixIdx, length); + radixIdxTmp = ensureCapacity(radixIdxTmp, length); + for (int i = 0; i < length; i++) { + radixKeys[i] = RadixLongSort.zSortKey(array[i].getZ(slot)); + radixIdx[i] = i; + } + RadixLongSort.sortPairs(radixKeys, radixIdx, length, + radixKeysTmp, radixIdxTmp); + // Equal-key runs must resolve by ascending shapeId (the + // comparator's tie-break; queue order is NOT construction order). + // Runs are almost always singletons — the pack array only + // materializes for actual ties. + int runStart = 0; + while (runStart < length) { + int runEnd = runStart + 1; + final long key = radixKeys[runStart]; + while (runEnd < length && radixKeys[runEnd] == key) + runEnd++; + if (runEnd - runStart > 1) { + final int runLength = runEnd - runStart; + tiePack = ensureCapacity(tiePack, runLength); + for (int i = 0; i < runLength; i++) + tiePack[i] = + ((array[radixIdx[runStart + i]].shapeId + & 0xffffffffL) << 32) + | (radixIdx[runStart + i] & 0xffffffffL); + java.util.Arrays.sort(tiePack, 0, runLength); + for (int i = 0; i < runLength; i++) + radixIdx[runStart + i] = (int) tiePack[i]; + } + runStart = runEnd; + } + sortScratch = ensureCapacity(sortScratch, length); + for (int i = 0; i < length; i++) + sortScratch[i] = array[radixIdx[i]]; + System.arraycopy(sortScratch, 0, array, 0, length); + return true; + } + + /** + * Parallel merge sort over our own executor: chunk the array, + * sort chunks concurrently, then merge runs pairwise in a tree — + * every task recorded as KIND_SORT on the activity timeline. + */ + private void parallelMergeSort(final AbstractCoordinateShape[] array, + final int length, + final Comparator cmp, + final ExecutorService executor) { + final int cores = Runtime.getRuntime().availableProcessors(); + final int runCount = Math.min(length, cores * 4); + final int runSize = (length + runCount - 1) / runCount; + + // Phase 1: sort runs concurrently + runSortTasks(array, length, cmp, executor, runCount, runSize); + + // Phase 2: pairwise merge tree, in-place into the REUSED scratch + // array (previously a fresh multi-MB array per frame) + AbstractCoordinateShape[] from = array; + sortScratch = ensureCapacity(sortScratch, length); + final AbstractCoordinateShape[] scratch = sortScratch; + int width = runSize; + while (width < length) { + final int w = width; + final AbstractCoordinateShape[] src = from; + final AbstractCoordinateShape[] dst = (from == array) ? scratch : array; + final java.util.List> futures = new java.util.ArrayList<>(); + for (int start = 0; start < length; start += 2 * w) { + final int left = start; + final int mid = Math.min(start + w, length); + final int right = Math.min(start + 2 * w, length); + futures.add(executor.submit(() -> { + final boolean trace = eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.isEnabled(); + final long t0 = trace ? System.nanoTime() : 0; + try { + mergeRuns(src, dst, cmp, left, mid, right); + } finally { + if (trace) + eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.record( + eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.KIND_SORT, + t0, System.nanoTime()); + } + })); + } + awaitAll(futures, "parallel sort merge"); + from = dst; + width *= 2; + } + if (from != array) + System.arraycopy(from, 0, array, 0, length); + } + + /** Sorts {@code runCount} consecutive runs of the array concurrently. */ + private void runSortTasks(final AbstractCoordinateShape[] array, + final int length, + final Comparator cmp, + final ExecutorService executor, + final int runCount, final int runSize) { + final java.util.List> futures = new java.util.ArrayList<>(runCount); + for (int r = 0; r < runCount; r++) { + final int from = r * runSize; + final int to = Math.min(length, from + runSize); + if (from >= to) + break; + futures.add(executor.submit(() -> { + final boolean trace = eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.isEnabled(); + final long t0 = trace ? System.nanoTime() : 0; + try { + Arrays.sort(array, from, to, cmp); + } finally { + if (trace) + eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.record( + eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.KIND_SORT, + t0, System.nanoTime()); + } + })); + } + awaitAll(futures, "parallel sort runs"); + } + + /** Merges two adjacent sorted runs [left,mid) and [mid,right) into dst. */ + private static void mergeRuns(final AbstractCoordinateShape[] src, + final AbstractCoordinateShape[] dst, + final Comparator cmp, + final int left, final int mid, final int right) { + int i = left, j = mid, k = left; + while (i < mid && j < right) + dst[k++] = cmp.compare(src[i], src[j]) <= 0 ? src[i++] : src[j++]; + while (i < mid) + dst[k++] = src[i++]; + while (j < right) + dst[k++] = src[j++]; + } + + private static void awaitAll(final java.util.List> futures, final String what) { + try { + for (final Future future : futures) + future.get(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during " + what, e); + } catch (final ExecutionException e) { + throw new RuntimeException("Task failed during " + what, e.getCause()); + } + } + + private void ensureSorted() { + sort(); + } + + /** + * Paints all shapes that have already been sorted. + * This method can be called multiple times with different segment contexts + * for multi-threaded rendering. + * + *

If {@link #binForTiles} was called and the given context's bounds + * exactly match one tile, only that tile's bin is iterated — shapes + * that cannot touch the tile are skipped entirely. Otherwise the full + * sorted queue is iterated (every shape clips itself to the context + * bounds, so the painted result is identical).

+ * + * @param renderBuffer the rendering context to paint shapes into + */ + public void paintSorted(final RenderingContext renderBuffer) { + // Two passes. Opaque class first, front-to-back (the queue is + // back-to-front, so iterate it in reverse) with depth test + + // write — early-z rejects hidden surfaces before texturing. + // Alpha class second, in queue order (back-to-front): + // depth-tested against the opaque result but never + // depth-written, so cutout foliage keeps painter-coherent + // overlap among itself. + renderBuffer.depthPass = 1; + paintRange(renderBuffer, true); + renderBuffer.depthPass = 2; + paintRange(renderBuffer, false); + renderBuffer.depthPass = 0; + } + + /** Iterates the matching bin (or the full sorted queue), optionally + * in reverse. */ + private void paintRange(final RenderingContext renderBuffer, + final boolean reverse) { + final int bin = matchingBinIndex(renderBuffer); + if (bin >= 0) { + final int start = binStart[bin]; + final int end = start + binCount[bin]; + if (reverse) { + for (int i = end - 1; i >= start; i--) + binEntries[i].paint(renderBuffer); + } else { + for (int i = start; i < end; i++) + binEntries[i].paint(renderBuffer); + } + return; + } + if (sortedArray != null) { + if (reverse) { + for (int i = sortedCount - 1; i >= 0; i--) + sortedArray[i].paint(renderBuffer); + } else { + for (int i = 0; i < sortedCount; i++) + sortedArray[i].paint(renderBuffer); + } + } else { + for (int i = 0; i < shapes.size(); i++) + shapes.get(i).paint(renderBuffer); + } + } + + /** + * Tile bins in CSR (compressed-sparse-row) form: one flat, + * grow-only entry array plus per-tile start/count. Built fresh + * each frame into REUSED arrays — the previous design allocated a + * fresh ArrayList per tile per chunk per frame (thousands of list + * objects plus their backing arrays at FO4 queue sizes). + */ + private AbstractCoordinateShape[] binEntries; + private int[] binStart; + private int[] binCount; + /** Per-(chunk × tile) scratch: counts in phase 1, write offsets in phase 3. */ + private int[] binScratch; + private int binTilesX; + private int binTilesY; + private int binOriginX; + private int binTileW; + private int binTileH; + private int binWidth; + private int binHeight; + private boolean binsActive; + + /** + * Returns the bin matching the given context's X/Y bounds, or -1 + * when the context does not correspond to exactly one binned tile. + * + * @param renderBuffer the rendering context to match + * @return the bin index, or -1 for full-queue iteration + */ + private int matchingBinIndex(final RenderingContext renderBuffer) { + if (!binsActive) + return -1; + final int tx = matchAxis(renderBuffer.renderMinX, renderBuffer.renderMaxX, + binOriginX, binTileW, binTilesX, binWidth); + if (tx < 0) + return -1; + final int ty = matchAxis(renderBuffer.renderMinY, renderBuffer.renderMaxY, + 0, binTileH, binTilesY, binHeight); + if (ty < 0) + return -1; + return ty * binTilesX + tx; + } + + /** + * Matches one axis of a context against the tile grid. + * + * @param min context minimum coordinate on this axis + * @param max context maximum coordinate (exclusive) on this axis + * @param origin grid origin on this axis + * @param tileSize tile size on this axis + * @param count tile count on this axis + * @param total total grid extent on this axis (last tile reaches it) + * @return the tile index on this axis, or -1 when no exact match + */ + private static int matchAxis(final int min, final int max, final int origin, + final int tileSize, final int count, final int total) { + final int rel = min - origin; + if (rel < 0 || rel % tileSize != 0) + return -1; + final int index = rel / tileSize; + if (index >= count) + return -1; + final int expectedMax = (index == count - 1) + ? origin + total : origin + (index + 1) * tileSize; + return max == expectedMax ? index : -1; + } + + /** + * Below this many queued shapes the bin build runs serially; the + * fork/join overhead would dominate. + */ + private static final int PARALLEL_BIN_MIN_SHAPES = 8192; + + /** + * Bins the sorted queue per rectangular paint tile by screen-space + * overlap. Must be called after {@link #sort()} and before tile + * painting. Built once per frame (per eye, in stereo) on the render + * thread; the bins are read-only during parallel painting. + * + *

Each bin preserves the global (Z, shapeId) sort order, so painting + * a bin produces exactly the same pixels as painting the full queue + * into that tile. Shapes are assigned with their + * {@link AbstractCoordinateShape#onScreenMinY} / {@code onScreenMaxY} / + * {@code onScreenMinX} / {@code onScreenMaxX} bounds, which include a + * per-shape margin for paint output extending past the vertices + * (thick lines, billboards, text glyphs).

+ * + *

Mouse hit detection is unaffected: a hit requires the cursor to be + * inside the shape, so the shape always overlaps the tile containing + * the cursor.

+ * + *

When an executor is given and the queue is large, the sorted list + * is scanned in per-core chunks concurrently and the per-chunk bins are + * concatenated in chunk order, preserving the global sort order.

+ * + * @param tilesX tile columns across the viewport + * @param tilesY tile rows down the viewport + * @param originX X origin of the tiled viewport (eye offset in stereo) + * @param width tiled viewport width in pixels + * @param height full render height in pixels + * @param executor executor for parallel binning, or null for serial + */ + public void binForTiles(final int tilesX, final int tilesY, + final int originX, final int width, final int height, + final ExecutorService executor) { + ensureSorted(); + + binsActive = false; + final int tileW = width / tilesX; + final int tileH = height / tilesY; + if (tilesX < 1 || tilesY < 1 || tileW <= 0 || tileH <= 0 + || sortedCount == 0) + return; + + buildBins(tilesX, tilesY, originX, tileW, tileH, + tilesX * tilesY, executor); + + binsActive = true; + binTilesX = tilesX; + binTilesY = tilesY; + binOriginX = originX; + binTileW = tileW; + binTileH = tileH; + binWidth = width; + binHeight = height; + } + + /** + * Floor of {@code value} as an int. The {@code (int)} cast truncates + * toward zero; decrement when the truncated value overshoots to get + * floor semantics for negatives. + */ + private static int floorInt(final double value) { + final int result = (int) value; + return result > value ? result - 1 : result; + } + + /** + * Builds the CSR bins in three phases over grow-only reused arrays: + * per-chunk counting (parallel), a tiny serial prefix pass, then + * per-chunk fill (parallel). Per-bin order is preserved exactly as + * with the old per-chunk-ArrayList concatenation: chunks cut the + * sorted queue contiguously and each bin's slices are laid down in + * chunk order. + */ + private void buildBins(final int tilesX, final int tilesY, + final int originX, final int tileW, final int tileH, + final int tileCount, + final ExecutorService executor) { + final AbstractCoordinateShape[] queue = sortedArray; + final int size = sortedCount; + final int targetTasks = Runtime.getRuntime().availableProcessors() * 4; + final int chunkSize = Math.max(1024, (size + targetTasks - 1) / targetTasks); + final int chunkCount = (size + chunkSize - 1) / chunkSize; + final double invTileW = 1.0 / tileW; + final double invTileH = 1.0 / tileH; + + if (binStart == null || binStart.length < tileCount) { + binStart = new int[tileCount]; + binCount = new int[tileCount]; + } + if (binScratch == null || binScratch.length < chunkCount * tileCount) { + binScratch = new int[chunkCount * tileCount]; + } else { + java.util.Arrays.fill(binScratch, 0, chunkCount * tileCount, 0); + } + + final boolean parallel = executor != null + && size >= PARALLEL_BIN_MIN_SHAPES && chunkCount > 1; + + // Phase 1: count shapes per (chunk, tile) + binPhase(queue, tilesX, tilesY, originX, invTileW, invTileH, + chunkCount, chunkSize, tileCount, true, parallel, executor); + + // Phase 2 (serial, tiny): per-tile prefix over chunks -> each + // chunk's write offset within the tile; per-tile totals -> + // binStart/binCount; overall capacity for the flat entries array + int total = 0; + for (int t = 0; t < tileCount; t++) { + int tileTotal = 0; + for (int c = 0; c < chunkCount; c++) { + final int idx = c * tileCount + t; + final int n = binScratch[idx]; + binScratch[idx] = tileTotal; + tileTotal += n; + } + binStart[t] = total; + binCount[t] = tileTotal; + total += tileTotal; + } + binEntries = ensureCapacity(binEntries, total); + + // Phase 3: fill; each chunk bumps only its own scratch row + binPhase(queue, tilesX, tilesY, originX, invTileW, invTileH, + chunkCount, chunkSize, tileCount, false, parallel, executor); + } + + /** Runs one bin phase over all chunks, inline or on the executor. */ + private void binPhase(final AbstractCoordinateShape[] queue, + final int tilesX, final int tilesY, + final int originX, + final double invTileW, final double invTileH, + final int chunkCount, final int chunkSize, + final int tileCount, + final boolean countMode, + final boolean parallel, + final ExecutorService executor) { + final int size = sortedCount; + final boolean trace = eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.isEnabled(); + final int traceKind = eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.KIND_BIN + + eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.frameParity(); + final java.util.List> futures = + parallel ? new java.util.ArrayList<>(chunkCount) : null; + for (int c = 0; c < chunkCount; c++) { + final int chunk = c; + final int from = c * chunkSize; + final int to = Math.min(size, from + chunkSize); + final Runnable body = () -> { + final long t0 = trace ? System.nanoTime() : 0; + try { + binRangeCsr(queue, tilesX, tilesY, originX, + invTileW, invTileH, from, to, + chunk * tileCount, countMode); + } finally { + if (trace) + eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.record( + traceKind, t0, System.nanoTime()); + } + }; + if (parallel) + futures.add(executor.submit(body)); + else + body.run(); + } + if (parallel) + awaitAll(futures, "parallel binning"); + } + + /** + * Assigns shapes[from..to) to tile bins by X/Y overlap. In count + * mode, increments binScratch per (chunk, tile); in fill mode, + * writes entries at binStart[tile] + running chunk offset. Both + * modes iterate in queue order, preserving (Z, shapeId) within bins. + */ + private void binRangeCsr(final AbstractCoordinateShape[] queue, + final int tilesX, final int tilesY, + final int originX, + final double invTileW, final double invTileH, + final int from, final int to, + final int scratchBase, + final boolean countMode) { + for (int i = from; i < to; i++) { + final AbstractCoordinateShape shape = queue[i]; + + int firstY = floorInt(shape.onScreenMinY(slot) * invTileH); + int lastY = floorInt(shape.onScreenMaxY(slot) * invTileH); + if (lastY < 0 || firstY >= tilesY) + continue; + if (firstY < 0) + firstY = 0; + if (lastY >= tilesY) + lastY = tilesY - 1; + + int firstX = floorInt((shape.onScreenMinX(slot) - originX) * invTileW); + int lastX = floorInt((shape.onScreenMaxX(slot) - originX) * invTileW); + if (lastX < 0 || firstX >= tilesX) + continue; + if (firstX < 0) + firstX = 0; + if (lastX >= tilesX) + lastX = tilesX - 1; + + for (int ty = firstY; ty <= lastY; ty++) { + final int rowBase = ty * tilesX; + for (int tx = firstX; tx <= lastX; tx++) { + final int tile = rowBase + tx; + if (countMode) { + binScratch[scratchBase + tile]++; + } else { + binEntries[binStart[tile] + + binScratch[scratchBase + tile]++] = shape; + } + } + } + } + } + + /** + * Returns the number of shapes currently queued. + * + * @return the shape count + */ + public int size() { + if (sortedArray != null) + return sortedCount; + if (pendingMergeCount > 0) + return pendingMergeCount; + return shapes.size(); + } + + /** + * Queues a shape for rendering. Called during the transform phase. + * + * @param shape the shape to queue + */ + public void queueShapeForRendering(final AbstractCoordinateShape shape) { + shapes.add(shape); + binsActive = false; + } + + /** + * Merges all shapes queued in another aggregator into this one. + * Used to combine the per-task queues produced by the parallel + * transform phase. Merge order does not affect the final render order: + * {@link #sort()} is deterministic on (Z, shapeId). + * + * @param other the aggregator whose queued shapes are moved into this one + */ + public void mergeFrom(final RenderAggregator other) { + shapes.addAll(other.shapes); + sorted = false; + binsActive = false; + } + + /** + * Merges many chunk aggregators into this one, producing a flat + * array that {@link #sort()} consumes directly. The per-chunk lists + * are copied into the merged array by parallel copy tasks on the + * given executor — the old per-chunk {@code addAll} chain + * (reallocating the target list serially) is gone. Merge order is + * irrelevant: the following sort re-establishes deterministic + * (Z, shapeId) order. + * + * @param parts chunk aggregators to merge + * @param executor executor for the parallel copy, or null for serial + */ + public void mergeAllParallel(final List parts, + final ExecutorService executor) { + final int ownSize = shapes.size(); + int total = ownSize; + for (final RenderAggregator part : parts) + total += part.shapes.size(); + + // Reused flat storage (grow-only); lists are copied out with + // plain indexed loops — no per-part toArray copies + queueArray = ensureCapacity(queueArray, total); + final AbstractCoordinateShape[] merged = queueArray; + final int partCount = parts.size(); + final int[] offsets = new int[partCount + 1]; + offsets[0] = ownSize; + for (int i = 0; i < partCount; i++) + offsets[i + 1] = offsets[i] + parts.get(i).shapes.size(); + + for (int j = 0; j < ownSize; j++) + merged[j] = shapes.get(j); + shapes.clear(); + + if (executor == null || partCount < 2 || total < 8192) { + for (int i = 0; i < partCount; i++) { + final ArrayList partShapes = parts.get(i).shapes; + for (int j = 0; j < partShapes.size(); j++) + merged[offsets[i] + j] = partShapes.get(j); + } + } else { + final int copyTasks = Math.min(partCount, + Runtime.getRuntime().availableProcessors()); + final int perTask = (partCount + copyTasks - 1) / copyTasks; + final List> futures = new ArrayList<>(copyTasks); + for (int t = 0; t < copyTasks; t++) { + final int from = t * perTask; + final int to = Math.min(partCount, from + perTask); + if (from >= to) + break; + futures.add(executor.submit(() -> { + for (int i = from; i < to; i++) { + final ArrayList partShapes = + parts.get(i).shapes; + final int partSize = partShapes.size(); + for (int j = 0; j < partSize; j++) + merged[offsets[i] + j] = partShapes.get(j); + } + })); + } + try { + for (final Future future : futures) + future.get(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during parallel merge", e); + } catch (final ExecutionException e) { + throw new RuntimeException("Parallel merge task failed", e.getCause()); + } + } + + pendingMergeCount = total; + sorted = false; + binsActive = false; + } + + /** + * Returns the live list of queued shapes, in current queue order. + * Package-private: exposed for pipeline verification tests. + * + * @return the queued shapes + */ + List getQueuedShapes() { + if (sortedArray != null) + return java.util.Collections.unmodifiableList( + Arrays.asList(sortedArray).subList(0, sortedCount)); + if (pendingMergeCount > 0) + return java.util.Collections.unmodifiableList( + Arrays.asList(queueArray).subList(0, pendingMergeCount)); + return shapes; + } + + /** + * Returns the number of shapes in each segment bin, or null when no + * binning is active. Package-private: exposed for pipeline + * verification tests. + * + * @return per-segment bin sizes, or null + */ + int[] getBinSizes() { + if (!binsActive) + return null; + return Arrays.copyOf(binCount, binTilesX * binTilesY); + } + + /** + * Clears all queued shapes, preparing for a new render frame. The + * reusable backing arrays (queue, sort scratch, bin storage) are + * kept, so steady-state frames do not reallocate them. + */ + public void reset() { + shapes.clear(); + sortedArray = null; + sortedCount = 0; + pendingMergeCount = 0; + sorted = false; + binsActive = false; + } + + /** + * Comparator that sorts shapes by Z-depth in descending order (farthest first) + * for the painter's algorithm. Uses shape ID as a tiebreaker. + */ + static class ShapesZIndexComparator implements Comparator, Serializable { + + /** Buffer slot the in-progress sort reads Z-depths from. */ + int sortSlot; + + @Override + public int compare(final AbstractCoordinateShape o1, final AbstractCoordinateShape o2) { + final double z1 = o1.getZ(sortSlot); + final double z2 = o2.getZ(sortSlot); + if (z1 < z2) + return 1; + else if (z1 > z2) + return -1; + + return Integer.compare(o1.shapeId, o2.shapeId); + } + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.java new file mode 100755 index 0000000..bf99292 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.java @@ -0,0 +1,548 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster; + +import eu.svjatoslav.aukio.e3d.geometry.Frustum; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.CullingStatistics; +import eu.svjatoslav.aukio.e3d.gui.Camera; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; +import eu.svjatoslav.aukio.e3d.math.Quaternion; +import eu.svjatoslav.aukio.e3d.math.Transform; +import eu.svjatoslav.aukio.e3d.math.TransformStack; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.SubShape; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.ExecutorService; + +/** + * Root container that holds all 3D shapes in a scene and orchestrates their rendering. + * + *

{@code ShapeCollection} is the top-level scene graph. You add shapes to it, and during + * each render frame it transforms all shapes from world space to screen space (relative to the + * camera), sorts them by depth, and paints them back-to-front.

+ * + *

Architecture:

+ *

The collection contains a single {@link AbstractCompositeShape} as its root container. + * This root composite:

+ *
    + *
  • Stores all scene shapes in its sub-shapes registry
  • + *
  • Triangulates N-vertex polygons (quads, etc.) into triangles during rendering
  • + *
  • Provides group-based visibility management (show/hide groups)
  • + *
  • Applies camera transform (position and rotation) to all shapes
  • + *
+ * + *

Usage example:

+ *
{@code
+ * // Get the root shape collection from the view panel
+ * ShapeCollection scene = viewPanel.getRootShapeCollection();
+ *
+ * // Add shapes to the scene
+ * scene.addShape(new Line(
+ *     new Point3D(0, 0, 100),
+ *     new Point3D(100, 0, 100),
+ *     Color.RED, 2.0
+ * ));
+ *
+ * // Add shapes with group identifier for visibility control
+ * scene.addShape(debugShape, "debug");
+ * scene.hideGroup("debug");  // hide all debug shapes
+ * scene.showGroup("debug");  // show them again
+ *
+ * // Add N-vertex polygons (quads, etc.) - automatically triangulated
+ * scene.addShape(SolidPolygon.quad(p1, p2, p3, p4, color));
+ * }
+ * + *

The {@link #addShape} method is synchronized, making it safe to add shapes from + * any thread while the rendering loop is active.

+ * + * @see ViewPanel#getRootShapeCollection() + * @see AbstractShape the base class for all shapes + * @see AbstractCompositeShape the root composite that stores and processes all shapes + * @see RenderAggregator handles depth sorting and painting + */ +public class ShapeCollection { + + /** + * Render aggregators that collect transformed shapes, sort by depth, and + * paint — one per projection buffer slot. The double-buffered pipeline + * fills one slot's aggregator during transform while the other slot's + * aggregator is still being painted. Slot 0 serves all single-buffered + * (serial) rendering. + */ + private final RenderAggregator[] aggregators = { new RenderAggregator(0), new RenderAggregator(1), + new RenderAggregator(2) }; + + /** + * The transform stack used during the rendering pipeline. + */ + private final TransformStack transformStack = new TransformStack(); + + /** + * Global transform-cycle counter; each transform pass gets a unique id + * for per-cycle memoization (composite subtree weights). + */ + private long transformCycleCounter; + + // Subpixel-culling verdict cache state (only advanced when the cull + // is enabled on the pass context): the epoch bumps on significant + // camera change, and unconditionally every CULL_MAX_FRAMES frames so + // shape-side transform changes cannot hide behind a stale verdict. + private static final double CULL_TRANSLATE_DELTA = Double.parseDouble( + System.getProperty("aukio.cull.subpixel.translate", "25")); + private static final double CULL_ROTATE_DELTA = Double.parseDouble( + System.getProperty("aukio.cull.subpixel.rotate", "0.01")); + private static final int CULL_MAX_FRAMES = Integer.parseInt( + System.getProperty("aukio.cull.subpixel.frames", "30")); + private int subpixelCullingEpoch; + private int cullEpochAge; + private double cullCamX = Double.NaN; + private double cullCamY; + private double cullCamZ; + private double cullCamQw; + private double cullCamQx; + private double cullCamQy; + private double cullCamQz; + + + // Camera rotation. We reuse this object for every frame render to avoid garbage collections. + private final Transform cameraRotationTransform = new Transform(); + + // Camera rotation. We reuse this object for every frame render to avoid garbage collections. + private final Transform cameraTranslationTransform = new Transform(); + + /** + * Root composite shape containing all scene shapes. + * + *

Handles:

+ *
    + *
  • N-gon triangulation (quads → triangles)
  • + *
  • Group-based visibility management
  • + *
  • Camera transform application
  • + *
  • LOD slicing for nested composites
  • + *
+ * + *

The transform is updated each frame to match the camera position and rotation.

+ */ + private final AbstractCompositeShape rootComposite; + + /** + * Creates a new empty shape collection with a root composite. + */ + public ShapeCollection() { + rootComposite = new AbstractCompositeShape(); + rootComposite.setRootComposite(true); + } + + /** + * Adds a shape to this collection without a group identifier. This method is thread-safe. + * + * @param shape the shape to add to the scene + */ + public synchronized void addShape(final AbstractShape shape) { + rootComposite.addShape(shape); + } + + /** + * Collects the triangles currently rendered for this collection (render + * lists of all composites, recursively). Used by derived structures like + * the global-illumination scene snapshot. Render lists build lazily + * during transform, so the result may be empty until the first frame. + * + * @param out list receiving the polygons + */ + public void collectRenderTriangles(final List out) { + rootComposite.collectRenderTriangles(out); + } + + /** + * Adds a shape to this collection with a group identifier for visibility control. This method is thread-safe. + * + *

Grouped shapes can be shown, hidden, or removed together using + * {@link #showGroup}, {@link #hideGroup}, and {@link #removeGroup}.

+ * + * @param shape the shape to add + * @param groupId the group identifier, or {@code null} for ungrouped shapes + */ + public synchronized void addShape(final AbstractShape shape, final String groupId) { + rootComposite.addShape(shape, groupId); + } + + /** + * Returns all shapes currently in this collection (including hidden ones). + * + *

This returns the sub-shapes from the registry, unwrapped from their {@link SubShape} + * containers. For access to group and visibility metadata, use {@link #getSubShapesRegistry()}.

+ * + * @return a collection of all shapes in the scene + */ + public Collection getShapes() { + final List result = new ArrayList<>(); + for (final SubShape subShape : rootComposite.getSubShapesRegistry()) { + result.add(subShape.getShape()); + } + return result; + } + + /** + * Returns the sub-shapes registry with group and visibility metadata. + * + *

This provides direct access to the registry for advanced operations + * like inspecting group assignments or visibility states.

+ * + * @return the list of sub-shapes with their metadata + */ + public List getSubShapesRegistry() { + return rootComposite.getSubShapesRegistry(); + } + + /** + * Removes all shapes from this collection. This method is thread-safe. + */ + public synchronized void clear() { + rootComposite.getSubShapesRegistry().clear(); + rootComposite.setCacheNeedsRebuild(true); + } + + /** + * Shows all shapes belonging to the specified group. + * + * @param groupId the group identifier to show + */ + public void showGroup(final String groupId) { + rootComposite.showGroup(groupId); + } + + /** + * Hides all shapes belonging to the specified group. + * Hidden shapes are not rendered but remain in the collection. + * + * @param groupId the group identifier to hide + */ + public void hideGroup(final String groupId) { + rootComposite.hideGroup(groupId); + } + + /** + * Permanently removes all shapes belonging to the specified group. + * + * @param groupId the group identifier to remove + */ + public void removeGroup(final String groupId) { + rootComposite.removeGroup(groupId); + } + + /** + * Returns all sub-shapes belonging to the specified group. + * + * @param groupId the group identifier to match + * @return list of matching sub-shapes + */ + public List getGroup(final String groupId) { + return rootComposite.getGroup(groupId); + } + + /** + * Transforms all shapes to screen space and queues them for rendering. + * This is phase 1 of the multi-threaded render pipeline. + * + *

Updates the root composite's transform to match the camera position and rotation, + * then delegates to the root composite's transform method which handles all shapes.

+ * + *

Frustum culling: The view frustum is computed from camera state and + * screen dimensions before transforming shapes. Composite shapes can test their + * bounding boxes against this frustum to skip invisible objects.

+ * + *

Culling statistics: Statistics are reset and total shape count computed + * at the start of each frame. Visible shapes are counted as they are queued.

+ * + * @param viewPanel the view panel providing the camera state + * @param renderingContext the rendering context with frame metadata + */ + public synchronized void transformShapes(final ViewPanel viewPanel, + final RenderingContext renderingContext) { + transformShapesBegin(viewPanel, renderingContext); + drainTransformShapes(renderingContext); + } + + /** + * Camera-based variant of {@link #transformShapes(ViewPanel, RenderingContext)} + * for headless rendering without a {@link ViewPanel} (off-screen snapshots, + * golden-image tests, GI scene setup). + * + * @param camera the camera providing position and orientation + * @param renderingContext the rendering context with frame metadata + */ + public synchronized void transformShapes(final Camera camera, + final RenderingContext renderingContext) { + transformShapesBegin(camera, renderingContext); + drainTransformShapes(renderingContext); + } + + /** + * First half of {@link #transformShapes}: resets the frame's + * aggregator, computes the frustum and walks the scene tree, + * forking heavy composites into chunk tasks on the frame's + * coordinator. Returns WITHOUT waiting for the chunk tasks; the + * caller hands the coordinator to {@link #drainTransformShapes} + * later (the pipeline drains it inside the asynchronous + * sort/bin/paint continuation on a worker thread). + * + * @param viewPanel the view panel providing the camera state + * @param renderingContext the pass rendering context + */ + public synchronized void transformShapesBegin(final ViewPanel viewPanel, + final RenderingContext renderingContext) { + transformShapesBegin(viewPanel.getCamera(), renderingContext); + } + + /** + * Camera-based variant of {@link #transformShapesBegin(ViewPanel, RenderingContext)} + * for headless rendering without a {@link ViewPanel}. + * + * @param camera the camera providing position and orientation + * @param renderingContext the pass rendering context + */ + public synchronized void transformShapesBegin(final Camera camera, + final RenderingContext renderingContext) { + + final RenderAggregator aggregator = aggregators[renderingContext.vertexSlot]; + aggregator.reset(); + transformStack.clear(); + renderingContext.transformCycleId = ++transformCycleCounter; + + // Update frustum for this frame (used for frustum culling) + if (renderingContext.frustum == null) { + renderingContext.frustum = new Frustum(); + } + renderingContext.frustum.update(camera, renderingContext.stereoViewportWidth, renderingContext.height); + + // Initialize culling statistics for this frame + if (renderingContext.cullingStatistics == null) { + renderingContext.cullingStatistics = new CullingStatistics(); + } + renderingContext.cullingStatistics.reset(); + // Note: totalShapes will be counted during rendering as shapes are queued + // This ensures we count actual rendered primitives (after triangulation/slicing) + + // final Transform rootTransform = rootComposite.getTransform(); + // TODO: Investigate if this transform can be reused instead of solution below + + cameraRotationTransform.getRotation().set(camera.getTransform().getRotation()); + cameraRotationTransform.invalidateCache(); + transformStack.addTransform(cameraRotationTransform); + + final Point3D cameraLocation = camera.getTransform().getTranslation(); + renderingContext.viewerPosition.x = cameraLocation.x; + renderingContext.viewerPosition.y = cameraLocation.y; + renderingContext.viewerPosition.z = cameraLocation.z; + + // Advance the subpixel-culling verdict epoch when the camera has + // moved significantly since the last bump (translation in world + // units; rotation compared on quaternion components, 0.01 ~ 1.1 + // degrees), or periodically. While the epoch holds, culled shapes + // skip their entire transform setup. + if (renderingContext.subpixelCullingThreshold > 0) { + final Quaternion camRot = camera.getTransform().getRotation(); + final boolean moved = Double.isNaN(cullCamX) + || Math.abs(cameraLocation.x - cullCamX) > CULL_TRANSLATE_DELTA + || Math.abs(cameraLocation.y - cullCamY) > CULL_TRANSLATE_DELTA + || Math.abs(cameraLocation.z - cullCamZ) > CULL_TRANSLATE_DELTA + || Math.abs(camRot.w - cullCamQw) > CULL_ROTATE_DELTA + || Math.abs(camRot.x - cullCamQx) > CULL_ROTATE_DELTA + || Math.abs(camRot.y - cullCamQy) > CULL_ROTATE_DELTA + || Math.abs(camRot.z - cullCamQz) > CULL_ROTATE_DELTA; + if (moved || ++cullEpochAge >= CULL_MAX_FRAMES) { + subpixelCullingEpoch++; + cullEpochAge = 0; + cullCamX = cameraLocation.x; + cullCamY = cameraLocation.y; + cullCamZ = cameraLocation.z; + cullCamQw = camRot.w; + cullCamQx = camRot.x; + cullCamQy = camRot.y; + cullCamQz = camRot.z; + } + renderingContext.subpixelCullingEpoch = subpixelCullingEpoch; + } + cameraTranslationTransform.getTranslation().x = -cameraLocation.x; + cameraTranslationTransform.getTranslation().y = -cameraLocation.y; + cameraTranslationTransform.getTranslation().z = -cameraLocation.z; + transformStack.addTransform(cameraTranslationTransform); + + // Non-blocking parallel fork: composites with enough children (at + // any nesting level) submit chunk tasks to the coordinator instead + // of transforming serially. The orchestrating thread (this one) is + // the only one allowed to block on task completion. + if (renderingContext.transformExecutor != null) { + renderingContext.transformCoordinator = + new ParallelTransformCoordinator(renderingContext.transformExecutor); + } + try { + rootComposite.transform(transformStack, aggregator, renderingContext); + } catch (final RuntimeException e) { + drainTransformShapes(renderingContext); + throw e; + } + } + + /** + * Second half of {@link #transformShapes}: waits for all transform + * chunk tasks and merges their aggregators into the frame's root + * aggregator. Safe to call from a worker thread while the render + * thread already walks the NEXT pass: chunk tasks capture their own + * transform-stack snapshots, and the aggregator is addressed by the + * pass's projection slot, so passes never touch the same state. + * + * @param renderingContext the pass rendering context + */ + public void drainTransformShapes(final RenderingContext renderingContext) { + if (renderingContext.transformCoordinator != null) { + renderingContext.transformCoordinator.drainAndMergeInto( + aggregators[renderingContext.vertexSlot]); + renderingContext.lastTransformTaskCount = + renderingContext.transformCoordinator.getSubmittedTaskCount(); + renderingContext.transformCoordinator = null; + } + } + + /** + * Sorts all queued shapes by Z-depth (back to front). + * This is phase 2 of the multi-threaded render pipeline. + */ + public void sortShapes() { + aggregators[0].sort(); + } + + /** + * Sorts the given buffer slot's queued shapes by Z-depth. + * + * @param slot buffer slot to sort (0 or 1) + */ + public void sortShapes(final int slot) { + aggregators[slot].sort(); + } + + /** + * Sorts the given buffer slot's queued shapes by Z-depth, parallelized + * over the given executor (instrumented parallel merge sort). + * + * @param slot buffer slot to sort + * @param executor executor for parallel sorting + */ + public void sortShapes(final int slot, final java.util.concurrent.ExecutorService executor) { + aggregators[slot].sort(executor); + } + + /** + * Bins the sorted render queue per rectangular paint tile by + * screen-space overlap, so each paint thread iterates only the + * shapes that can touch its tile instead of the whole queue. + * Call after {@link #sortShapes()}, before tile painting. + * + * @param tilesX tile columns across the viewport + * @param tilesY tile rows down the viewport + * @param originX X origin of the tiled viewport (eye offset in stereo) + * @param width tiled viewport width in pixels + * @param height full render height in pixels + * @param executor executor for parallel binning, or null for serial + */ + public void binShapesForTiles(final int tilesX, final int tilesY, + final int originX, final int width, final int height, + final ExecutorService executor) { + aggregators[0].binForTiles(tilesX, tilesY, originX, width, height, executor); + } + + /** + * Slot-selecting variant of + * {@link #binShapesForTiles(int, int, int, int, int, ExecutorService)} + * for the double-buffered pipeline. + * + * @param slot buffer slot whose queue gets binned (0 or 1) + * @param tilesX tile columns across the viewport + * @param tilesY tile rows down the viewport + * @param originX X origin of the tiled viewport (eye offset in stereo) + * @param width tiled viewport width in pixels + * @param height full render height in pixels + * @param executor executor for parallel binning, or null for serial + */ + public void binShapesForTiles(final int slot, final int tilesX, final int tilesY, + final int originX, final int width, final int height, + final ExecutorService executor) { + aggregators[slot].binForTiles(tilesX, tilesY, originX, width, height, executor); + } + + /** + * Paints all already-sorted shapes to the rendering context. + * This is phase 3 of the multi-threaded render pipeline. + * Can be called multiple times with different segment contexts. + * + * @param renderingContext the rendering context to paint into + */ + public void paintShapes(final RenderingContext renderingContext) { + aggregators[renderingContext.vertexSlot].paintSorted(renderingContext); + } + + /** + * Returns the number of shapes queued for rendering. + * + * @return the shape count + */ + public int getQueuedShapeCount() { + return aggregators[0].size(); + } + + /** + * Returns the live list of shapes queued in the aggregator. + * Package-private: exposed for pipeline verification tests. + * + * @return the queued shapes + */ + List getQueuedShapes() { + return aggregators[0].getQueuedShapes(); + } + + /** + * Returns the number of shapes in each paint-segment bin, or null when + * no binning is active. Package-private: exposed for pipeline + * verification tests. + * + * @return per-segment bin sizes, or null + */ + int[] getBinSizes() { + return aggregators[0].getBinSizes(); + } + + /** + * Returns the root composite shape containing all scene shapes. + * + *

Useful for headless setups that must transform the scene without + * going through the full collection pipeline (e.g. pre-building render + * lists before a GI snapshot reads them via + * {@link #collectRenderTriangles}).

+ * + * @return the root composite (never null) + */ + public AbstractCompositeShape getRootComposite() { + return rootComposite; + } + + /** + * Sets the cache rebuild flag on the root composite. + * + *

Used internally to force a render-list rebuild. Public for advanced use cases.

+ * + * @param needsRebuild {@code true} to force cache rebuild + */ + public void setCacheNeedsRebuild(final boolean needsRebuild) { + rootComposite.setCacheNeedsRebuild(needsRebuild); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java new file mode 100644 index 0000000..c9ae4ef --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java @@ -0,0 +1,896 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.gi; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection; +import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.GiLightProvider; +import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightSource; +import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Progressive CPU global illumination, running on dedicated low-priority + * threads (never on the render ForkJoinPool). + * + *

Two sampling resolutions:

+ *
    + *
  • Lightmapped triangles ({@link LightmappedShape}, e.g. the + * wrapped polygons of a lightmapping-enabled + * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}): + * per-texel sampling. Shadows and gradients live INSIDE the polygon + * surface; the painted texture is the premultiplied composite + * (baseColor x ambient+direct+indirect), regenerated on GI threads + * and swapped in double-buffered — painters never see a half-updated + * texture.
  • + *
  • Plain solid polygons: per-polygon sampling; the result feeds + * the flat-shading path through {@link GiLightProvider} (shadow tests + * + indirect add). Polygons stay single-colored.
  • + *
+ * + *

Estimator: each sample casts one cosine-weighted hemisphere ray + * from the surface. At the hit it evaluates direct light with cached shadow + * tests (next-event estimation) plus the hit surface's current indirect + * estimate, so bounce light propagates deeper over sweeps without an + * explicit recursion limit. Two nested exponential moving averages shape + * what the user sees: the inner per-sample EMA smooths Monte Carlo noise in + * the indirect term, and the outer per-composite-update EMA wraps the + * complete sum ambient+direct+indirect with a fixed alpha — lightmaps start + * at a uniform medium irradiance and glide to the traced solution (lit + * areas brighten, unlit areas sink to darkness), so no black-to-lit flash + * or shadow pop is possible. Scene or light changes rebuild the + * snapshot and restart convergence. When converged, workers idle at a low + * cadence instead of burning CPU.

+ * + *

Render-side cost: zero ray casting. Lightmapped triangles paint + * from their current composite texture; the flat-shading path only reads + * cached per-polygon values. Frame rate is unaffected by GI quality.

+ * + *

Limitations: diffuse light only; polygon vertices are used in + * composite-local space, so scenes combining composites with non-identity + * transforms are traced incorrectly.

+ * + *

Usage:

+ *
{@code
+ * GlobalIllumination gi = viewPanel.enableGlobalIllumination(); // 2 threads
+ * }
+ */ +public class GlobalIllumination implements GiLightProvider { + + /** + * Diffuse bounce gain: outgoing radiance is albedo/pi times irradiance + * (the pi comes from cosine-weighted hemisphere integration). Without it + * the indirect feedback loop converges to several times the direct + * energy and the scene saturates. + */ + private static final double BOUNCE_GAIN = 1.0 / Math.PI; + + /** Origin offset along the surface normal to avoid self-intersection. */ + private static final double ORIGIN_EPSILON = 0.5; + + /** Shadow ray segments end this far before the light to avoid grazing hits. */ + private static final double LIGHT_EPSILON = 1.0; + + /** Minimum sleep between sweeps once the solution has converged. */ + private static final long IDLE_SLEEP_MS = 250; + + /** Maximum converged-state sleep: edits restart tracing at this latency. */ + private static final long IDLE_SLEEP_MAX_MS = 2000; + + /** Minimum interval between composite texture updates. */ + private static final long COMPOSITE_INTERVAL_MS = 500; + + /** Per-light visibility is tracked for at most this many lights. */ + private static final int MAX_TRACKED_LIGHTS = 16; + + /** Debug statistics with {@code -De3d.gi.debug}. */ + private static final boolean DEBUG = Boolean.getBoolean("e3d.gi.debug"); + + /** + * EMA policy for the inner per-sample indirect blend: "fixed" (default, + * 0.15) keeps every ray hit equally intensive forever — fading toward + * darkness stays as alive as brightening. "adaptive" decays alpha with + * sample count (lower final noise, but late-time adaptation nearly + * stops). {@code -De3d.gi.alphaMode}. + */ + private static final String ALPHA_MODE = System.getProperty("e3d.gi.alphaMode", "fixed"); + /** Adaptive alpha floor: keeps post-change fade alive. */ + private static final double ALPHA_FLOOR = Double.parseDouble(System.getProperty("e3d.gi.alphaFloor", "0.08")); + /** Spatial despeckle of indirect at composite time. */ + private static final boolean DESPECKLE = Boolean.parseBoolean(System.getProperty("e3d.gi.despeckle", "true")); + + /** + * Outer EMA: fraction of the freshly computed total irradiance blended + * into the on-screen composite estimate per update. Lower = slower, + * calmer fade (and less visible Monte Carlo noise); higher = faster + * reaction. At the default 0.2 and 500 ms update cadence the scene + * reaches its true lighting in roughly 5 seconds. + * {@code -De3d.gi.compositeAlpha}. + */ + private static final double COMPOSITE_ALPHA = + Double.parseDouble(System.getProperty("e3d.gi.compositeAlpha", "0.2")); + + /** + * Convergence: declared after five consecutive composite updates whose + * average per-texel estimate movement falls below this many light + * units. With a constant alpha the estimate never freezes completely + * (Monte Carlo jitter), so this judges the VISIBLE movement, not the + * per-sample deltas. {@code -De3d.gi.calmThreshold}. + */ + private static final double CALM_THRESHOLD = + Double.parseDouble(System.getProperty("e3d.gi.calmThreshold", "1.0")); + + private final ShapeCollection shapes; + private final LightingManager lightingManager; + private final int threadCount; + + private final List threads = new ArrayList<>(); + private volatile boolean running; + + // --- Snapshot (rebuilt on scene/light change; published via volatile) --- + + private volatile Snapshot snapshot; + + /** Per-polygon state for PLAIN solid polygons, read by render threads. */ + private final ConcurrentHashMap states = new ConcurrentHashMap<>(); + + private int lastSeenRenderListVersion = -1; + private double lastLightSignature = Double.NaN; + private final AtomicInteger workIndex = new AtomicInteger(); + private volatile int calmSweeps; + private volatile long lastCompositeUpdate; + private final java.util.concurrent.atomic.AtomicBoolean compositeUpdateInFlight = + new java.util.concurrent.atomic.AtomicBoolean(); + + private static class Snapshot { + List entries; + TriangleBvh bvh; + List lights; + IdentityHashMap lightIndex; + double ambientR, ambientG, ambientB; + /** Flattened work list: one item per lightmap texel / plain polygon. */ + WorkItem[] workItems; + /** All lightmaps in the snapshot (for composite updates). */ + List lightmaps; + } + + /** One unit of GI work: a lightmap texel, or a whole plain polygon. */ + private static class WorkItem { + TriangleBvh.Entry entry; + int texel; // -1 = plain polygon + } + + /** Progressive per-polygon GI state for plain solid polygons. */ + private static class GiState { + volatile float indirectR, indirectG, indirectB; // irradiance, light units + volatile long visibleBits; // per-light: shadow ray says visible + volatile long knownBits; // per-light: visibility computed at least once + int nextLight; // round-robin cursor (GI threads only) + int samples; // adaptive EMA counter (GI threads only) + } + + /** + * Creates the GI system. Call {@link #start()} to begin tracing. + * + * @param shapes the scene to trace + * @param lightingManager the lights to sample + * @param threadCount dedicated worker threads (2 is a good default) + */ + public GlobalIllumination(final ShapeCollection shapes, + final LightingManager lightingManager, + final int threadCount) { + this.shapes = shapes; + this.lightingManager = lightingManager; + this.threadCount = Math.max(1, threadCount); + } + + /** Registers the GI provider and starts the worker threads. */ + public void start() { + if (running) + return; + running = true; + lightingManager.setGiProvider(this); + for (int i = 0; i < threadCount; i++) { + final Thread thread = new Thread(this::workLoop, "e3d-gi-" + i); + thread.setDaemon(true); + thread.setPriority(Thread.MIN_PRIORITY); + threads.add(thread); + thread.start(); + } + } + + /** Stops the worker threads and unregisters the provider. */ + public void stop() { + running = false; + lightingManager.setGiProvider(null); + for (final Thread thread : threads) + thread.interrupt(); + threads.clear(); + } + + /** + * Returns whether the GI worker threads are running. + * + * @return {@code true} after {@link #start()} and before {@link #stop()} + */ + public boolean isRunning() { + return running; + } + + /** + * Returns whether the solution has converged (workers idling at a low + * duty cycle). Convergence is declared after five consecutive composite + * updates whose average per-texel estimate movement is below + * {@code e3d.gi.calmThreshold} (default 1.0 light unit). + * + * @return {@code true} when converged + */ + public boolean isConverged() { + return calmSweeps >= 5; + } + + /** + * Returns the number of work items in the current scene snapshot + * (one per lightmap texel plus one per plain polygon), or 0 when no + * snapshot has been built yet. + * + * @return the work item count + */ + public int getWorkItemCount() { + final Snapshot snap = snapshot; + return snap == null || snap.workItems == null ? 0 : snap.workItems.length; + } + + // ------------------------------------------------------------------ + // GiLightProvider — plain-polygon path, called from parallel + // render-pool threads. Must be fast, thread-safe, allocation-free. + // ------------------------------------------------------------------ + + @Override + public boolean isLightVisible(final SolidPolygon polygon, final LightSource light) { + final Snapshot snap = snapshot; + if (snap == null) + return true; + final Integer index = snap.lightIndex.get(light); + if (index == null || index >= 64) + return true; + final GiState state = states.get(polygon); + if (state == null) + return true; // not computed yet: shadows fade in, never pop out + final long bit = 1L << index; + if ((state.knownBits & bit) == 0) + return true; + return (state.visibleBits & bit) != 0; + } + + @Override + public void addIndirectLight(final SolidPolygon polygon, final Color baseColor, final Color result) { + final GiState state = states.get(polygon); + if (state == null) + return; + final int r = result.r + (int) (state.indirectR * baseColor.r / 255f); + final int g = result.g + (int) (state.indirectG * baseColor.g / 255f); + final int b = result.b + (int) (state.indirectB * baseColor.b / 255f); + result.set(Math.min(255, r), Math.min(255, g), Math.min(255, b), result.a); + } + + // ------------------------------------------------------------------ + // Worker threads + // ------------------------------------------------------------------ + + private void workLoop() { + final TriangleBvh.Hit hit = new TriangleBvh.Hit(); + final double[] pos = new double[3]; + while (running) { + try { + maybeRebuildSnapshot(); + final Snapshot snap = snapshot; + if (snap == null || snap.workItems.length == 0) { + Thread.sleep(100); + continue; + } + + // One sweep over all work items. Convergence is judged by + // composite-estimate movement inside updateComposites() + // (per-sample deltas are Monte Carlo noise and, with the + // fixed alpha, never settle). + final long sweepStart = System.currentTimeMillis(); + final int size = snap.workItems.length; + double deltaSum = 0; + for (int i = 0; i < size && running; i++) { + final int index = Math.floorMod(workIndex.getAndIncrement(), size); + deltaSum += sample(snap, snap.workItems[index], hit, pos); + } + final double avgDelta = deltaSum / size; + + // Regenerate composite textures at most every + // COMPOSITE_INTERVAL_MS; a paint pass is one texture swap + // per triangle, invisible to the render threads. Also + // advances the convergence counter. + final long now = System.currentTimeMillis(); + if (now - lastCompositeUpdate >= COMPOSITE_INTERVAL_MS) { + updateComposites(snap); + lastCompositeUpdate = now; + } + + if (DEBUG) + System.out.println("[GI] sweep done, avgDelta=" + String.format("%.2f", avgDelta) + + ", calmSweeps=" + calmSweeps); + + if (isConverged()) { + // Converged: cap duty cycle at ~50% of sweep time + // (a big scene's sweep takes seconds; a flat 250ms + // sleep would barely throttle it). Hard cap keeps + // post-edit re-convergence prompt. + final long sweepMillis = System.currentTimeMillis() - sweepStart; + Thread.sleep(Math.min(IDLE_SLEEP_MAX_MS, + Math.max(IDLE_SLEEP_MS, sweepMillis))); + } + } catch (final InterruptedException e) { + return; + } catch (final Exception e) { + e.printStackTrace(); + try { + Thread.sleep(500); + } catch (final InterruptedException ie) { + return; + } + } + } + } + + /** One progressive sample: one shadow ray + one bounce ray. */ + private double sample(final Snapshot snap, final WorkItem item, + final TriangleBvh.Hit hit, final double[] pos) { + final TriangleBvh.Entry entry = item.entry; + final Lightmap lightmap = entry.lightmap; + + final double ox, oy, oz, nx, ny, nz; + if (lightmap != null) { + lightmap.texelWorldPosition(item.texel, pos); + nx = lightmap.normalX; + ny = lightmap.normalY; + nz = lightmap.normalZ; + ox = pos[0] + nx * ORIGIN_EPSILON; + oy = pos[1] + ny * ORIGIN_EPSILON; + oz = pos[2] + nz * ORIGIN_EPSILON; + } else { + nx = entry.normal[0]; + ny = entry.normal[1]; + nz = entry.normal[2]; + ox = entry.centroidX + nx * ORIGIN_EPSILON; + oy = entry.centroidY + ny * ORIGIN_EPSILON; + oz = entry.centroidZ + nz * ORIGIN_EPSILON; + } + + // 1. Shadow rays. First visit per texel: test ALL lights, so direct + // light + hard shadows appear after one sweep instead of trickling + // in over lightCount sweeps. Afterwards: one light, round-robin. + final int lightCount = snap.lights.size(); + if (lightCount > 0) { + if (lightmap != null) { + lightmap.ensureLightCapacity(lightCount); + final boolean firstVisit = lightmap.sampleCounts[item.texel] == 0; + if (firstVisit) { + for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++) + lightmap.lightVisibility[item.texel * lightCount + i] = + shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(i)) + ? Lightmap.VISIBILITY_VISIBLE : Lightmap.VISIBILITY_OCCLUDED; + } else { + final int lightIdx = lightmap.nextLight++ % lightCount; + lightmap.lightVisibility[item.texel * lightCount + lightIdx] = + shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(lightIdx)) + ? Lightmap.VISIBILITY_VISIBLE : Lightmap.VISIBILITY_OCCLUDED; + } + } else { + final GiState state = states.computeIfAbsent(entry.polygon, p -> new GiState()); + final int lightIdx = state.nextLight++ % lightCount; + final boolean visible = shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(lightIdx)); + final long bit = 1L << lightIdx; + synchronized (state) { + state.visibleBits = visible ? (state.visibleBits | bit) : (state.visibleBits & ~bit); + state.knownBits |= bit; + } + } + } + + // 2. Bounce ray: cosine-weighted hemisphere around the normal. + final double[] dir = cosineHemisphere(nx, ny, nz, ThreadLocalRandom.current()); + + double targetR = 0, targetG = 0, targetB = 0; + if (snap.bvh.nearest(ox, oy, oz, dir[0], dir[1], dir[2], hit)) { + // Direct irradiance at the hit point (clamped to display range) + // plus the hit surface's current indirect estimate. + final double[] irr = directIrradiance(snap, hit); + final Color hitColor = colorOf(hit.entry); + final float hiR, hiG, hiB; + if (hit.entry.lightmap != null) { + final int hitTexel = hit.entry.lightmap.texelAt(hit.pointX, hit.pointY, hit.pointZ); + hiR = hit.entry.lightmap.indirectR[hitTexel]; + hiG = hit.entry.lightmap.indirectG[hitTexel]; + hiB = hit.entry.lightmap.indirectB[hitTexel]; + } else { + final GiState hitState = states.get(hit.entry.polygon); + hiR = hitState == null ? 0 : hitState.indirectR; + hiG = hitState == null ? 0 : hitState.indirectG; + hiB = hitState == null ? 0 : hitState.indirectB; + } + targetR = BOUNCE_GAIN * hitColor.r * (irr[0] + hiR) / 255.0; + targetG = BOUNCE_GAIN * hitColor.g * (irr[1] + hiG) / 255.0; + targetB = BOUNCE_GAIN * hitColor.b * (irr[2] + hiB) / 255.0; + } + + // Inner EMA update. "fixed" mode (default): every ray hit lands + // with the same weight forever, so unlit areas keep fading to + // darkness at the same rate lit areas brighten. "adaptive" mode: + // alpha starts at ~1 and decays with sample count (floored). + if (lightmap != null) { + final int count = Math.min(32000, ++lightmap.sampleCounts[item.texel]); + final float alpha = "fixed".equals(ALPHA_MODE) ? 0.15f + : (float) Math.max(ALPHA_FLOOR, 2f / (2f + count)); + final float dR = (float) (targetR - lightmap.indirectR[item.texel]); + final float dG = (float) (targetG - lightmap.indirectG[item.texel]); + final float dB = (float) (targetB - lightmap.indirectB[item.texel]); + lightmap.indirectR[item.texel] += alpha * dR; + lightmap.indirectG[item.texel] += alpha * dG; + lightmap.indirectB[item.texel] += alpha * dB; + return Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha; + } else { + final GiState state = states.computeIfAbsent(entry.polygon, p -> new GiState()); + synchronized (state) { + final int count = Math.min(32000, ++state.samples); + final float alpha = "fixed".equals(ALPHA_MODE) ? 0.15f + : (float) Math.max(ALPHA_FLOOR, 2f / (2f + count)); + final float dR = (float) (targetR - state.indirectR); + final float dG = (float) (targetG - state.indirectG); + final float dB = (float) (targetB - state.indirectB); + state.indirectR += alpha * dR; + state.indirectG += alpha * dG; + state.indirectB += alpha * dB; + return Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha; + } + } + } + + /** Shadow ray from a surface point toward a light. */ + private boolean shadowTest(final Snapshot snap, + final double ox, final double oy, final double oz, + final double nx, final double ny, final double nz, + final LightSource light) { + final Point3D lightPos = light.getPosition(); + final double dx = lightPos.x - ox; + final double dy = lightPos.y - oy; + final double dz = lightPos.z - oz; + final double dist = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (dist < 1.0) + return true; + + // Light behind the surface never illuminates it. + if ((dx * nx + dy * ny + dz * nz) / dist <= 0) + return false; + + final double maxT = dist - LIGHT_EPSILON; + if (maxT <= 0) + return true; + return !snap.bvh.occluded(ox, oy, oz, dx / dist, dy / dist, dz / dist, maxT); + } + + /** + * Direct irradiance at a ray hit point, same scale as LightingManager, + * clamped to display range: light units near a lamp can sum far beyond + * 255 and feeding unbounded energy into the bounce loop saturates the + * scene. Uses the hit surface's CACHED visibility (no new shadow rays). + */ + private double[] directIrradiance(final Snapshot snap, final TriangleBvh.Hit hit) { + final TriangleBvh.Entry entry = hit.entry; + final Lightmap lightmap = entry.lightmap; + + double r = snap.ambientR, g = snap.ambientG, b = snap.ambientB; + final int lightCount = snap.lights.size(); + final int hitTexel = lightmap != null + ? lightmap.texelAt(hit.pointX, hit.pointY, hit.pointZ) : -1; + final GiState hitState = lightmap == null ? states.get(entry.polygon) : null; + + for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++) { + final LightSource light = snap.lights.get(i); + + if (lightmap != null) { + if (lightmap.lightVisibility != null + && lightmap.lightVisibility[hitTexel * lightCount + i] == Lightmap.VISIBILITY_OCCLUDED) + continue; + } else if (hitState != null) { + final long bit = 1L << i; + if ((hitState.knownBits & bit) != 0 && (hitState.visibleBits & bit) == 0) + continue; + } + + final Point3D lightPos = light.getPosition(); + final double dx = lightPos.x - hit.pointX; + final double dy = lightPos.y - hit.pointY; + final double dz = lightPos.z - hit.pointZ; + final double dist = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (dist < 0.0001) + continue; + final double dot = (entry.normal[0] * dx + entry.normal[1] * dy + entry.normal[2] * dz) / dist; + if (dot <= 0) + continue; + final double attenuation = 1.0 / (1.0 + 0.0001 * dist * dist); + final double intensity = dot * attenuation * light.getIntensity(); + final Color lightColor = light.getColor(); + r += lightColor.r * intensity; + g += lightColor.g * intensity; + b += lightColor.b * intensity; + } + return new double[]{Math.min(255, r), Math.min(255, g), Math.min(255, b)}; + } + + /** Surface color of a snapshot entry (albedo source). */ + private static Color colorOf(final TriangleBvh.Entry entry) { + if (entry.lightmap != null) + return entry.lightmap.baseColor; + return ((SolidPolygon) entry.polygon).getColor(); + } + + // ------------------------------------------------------------------ + // Composite textures: baseColor x (ambient + direct with shadows + + // indirect), regenerated into the back buffer and swapped in. + // ------------------------------------------------------------------ + + private void updateComposites(final Snapshot snap) { + // Single flight: both workers finish sweeps concurrently and must + // not write the same back buffers simultaneously. + if (!compositeUpdateInFlight.compareAndSet(false, true)) + return; + try { + final int lightCount = snap.lights.size(); + final double[] pos = new double[3]; + double movementSum = 0; + long texelTotal = 0; + for (final Lightmap lightmap : snap.lightmaps) { + lightmap.ensureLightCapacity(lightCount); + final int width = lightmap.width; + final int height = lightmap.height; + final int texelCount = width * height; + + // 1. Total irradiance per valid texel (float, no clamping yet). + final float[] irrR = new float[texelCount]; + final float[] irrG = new float[texelCount]; + final float[] irrB = new float[texelCount]; + for (final int texel : lightmap.validTexels) { + lightmap.texelWorldPosition(texel, pos); + double r = snap.ambientR, g = snap.ambientG, b = snap.ambientB; + for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++) { + if (lightmap.lightVisibility[texel * lightCount + i] == Lightmap.VISIBILITY_OCCLUDED) + continue; + final LightSource light = snap.lights.get(i); + final Point3D lightPos = light.getPosition(); + final double dx = lightPos.x - pos[0]; + final double dy = lightPos.y - pos[1]; + final double dz = lightPos.z - pos[2]; + final double dist = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (dist < 0.0001) + continue; + final double dot = (lightmap.normalX * dx + lightmap.normalY * dy + + lightmap.normalZ * dz) / dist; + if (dot <= 0) + continue; + final double attenuation = 1.0 / (1.0 + 0.0001 * dist * dist); + final double intensity = dot * attenuation * light.getIntensity(); + final Color lightColor = light.getColor(); + r += lightColor.r * intensity; + g += lightColor.g * intensity; + b += lightColor.b * intensity; + } + // Indirect, lightly blended with valid 4-neighbors: + // single-texel Monte Carlo spikes are smoothed without + // blurring real gradients (texels are sub-pixel at 4K). + final float smoothedR = DESPECKLE ? smoothedIndirect(lightmap.indirectR, lightmap, texel) : lightmap.indirectR[texel]; + final float smoothedG = DESPECKLE ? smoothedIndirect(lightmap.indirectG, lightmap, texel) : lightmap.indirectG[texel]; + final float smoothedB = DESPECKLE ? smoothedIndirect(lightmap.indirectB, lightmap, texel) : lightmap.indirectB[texel]; + irrR[texel] = (float) Math.min(255, r) + smoothedR; + irrG[texel] = (float) Math.min(255, g) + smoothedG; + irrB[texel] = (float) Math.min(255, b) + smoothedB; + } + + // 2. Fill the invalid half (u+v > 1) from nearest valid + // neighbors, so bilinear upsampling never reads garbage. + final boolean[] filled = new boolean[texelCount]; + for (final int texel : lightmap.validTexels) + filled[texel] = true; + boolean progressed = true; + while (progressed) { + progressed = false; + for (int t = 0; t < texelCount; t++) { + if (filled[t]) + continue; + final int i = t % width; + final int j = t / width; + final int left = i > 0 ? t - 1 : -1; + final int right = i < width - 1 ? t + 1 : -1; + final int up = j > 0 ? t - width : -1; + final int down = j < height - 1 ? t + width : -1; + final int source = left >= 0 && filled[left] ? left + : right >= 0 && filled[right] ? right + : up >= 0 && filled[up] ? up + : down >= 0 && filled[down] ? down : -1; + if (source >= 0) { + irrR[t] = irrR[source]; + irrG[t] = irrG[source]; + irrB[t] = irrB[source]; + filled[t] = true; + progressed = true; + } + } + } + + // 3. Blend the computed irradiance into the persistent + // per-texel estimate (the outer EMA), then write the + // composite texture 1:1 from the ESTIMATE — the texture + // can only move COMPOSITE_ALPHA of the remaining + // distance per update, so direct light, shadows and + // indirect all fade in/out gradually. + final Texture back = lightmap.backTexture(); + final int[] pixels = back.primaryBitmap.pixels; + for (int j = 0; j < height; j++) + for (int i = 0; i < width; i++) { + final int t = j * width + i; + final float dR = (float) (COMPOSITE_ALPHA * (irrR[t] - lightmap.estimateR[t])); + final float dG = (float) (COMPOSITE_ALPHA * (irrG[t] - lightmap.estimateG[t])); + final float dB = (float) (COMPOSITE_ALPHA * (irrB[t] - lightmap.estimateB[t])); + lightmap.estimateR[t] += dR; + lightmap.estimateG[t] += dG; + lightmap.estimateB[t] += dB; + movementSum += Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))); + texelTotal++; + pixels[t] = compositePixel(lightmap, + lightmap.estimateR[t], lightmap.estimateG[t], lightmap.estimateB[t]); + } + + back.resetResampledBitmapCache(); + if (lightmap.owner != null) { + lightmap.owner.setTexture(back); + lightmap.swapBuffers(); + } + + // Debug: -De3d.gi.dumpLightmaps=/tmp/lm dumps composites as PNGs. + if (DUMP_DIR != null) + dumpLightmap(lightmap, pixels); + } + + // Convergence: average per-texel movement of the on-screen + // estimate. With a constant alpha the estimate never fully + // freezes (Monte Carlo jitter), so CALM_THRESHOLD judges the + // VISIBLE movement; five calm updates in a row -> idle. + final double avgMovement = texelTotal > 0 ? movementSum / texelTotal : 0; + if (avgMovement < CALM_THRESHOLD) + calmSweeps++; + else + calmSweeps = 0; + if (DEBUG) + System.out.println("[GI] composite update, avgMovement=" + + String.format("%.2f", avgMovement)); + } finally { + compositeUpdateInFlight.set(false); + } + } + + private static final String DUMP_DIR = System.getProperty("e3d.gi.dumpLightmaps"); + private static int dumpCounter; + + private static void dumpLightmap(final Lightmap lightmap, final int[] pixels) { + if (dumpCounter++ % 173 != 0) // spread dumps across lightmaps + return; + try { + final int scale = 8; + final int w = lightmap.width; + final int h = lightmap.height; + final java.awt.image.BufferedImage image = new java.awt.image.BufferedImage( + w * scale, h * scale, java.awt.image.BufferedImage.TYPE_INT_RGB); + for (int j = 0; j < h * scale; j++) + for (int i = 0; i < w * scale; i++) + image.setRGB(i, j, pixels[(j / scale) * w + (i / scale)]); + final java.io.File dir = new java.io.File(DUMP_DIR); + dir.mkdirs(); + final String name = String.format("%s/lm-%03d-%dx%d-(%.0f,%.0f,%.0f).png", DUMP_DIR, + dumpCounter, w, h, + lightmap.originX, lightmap.originY, lightmap.originZ); + javax.imageio.ImageIO.write(image, "png", new java.io.File(name)); + } catch (final Exception e) { + e.printStackTrace(); + } + } + + /** + * Indirect value blended 50/50 with the mean of valid 4-neighbors. + * Kills single-texel Monte Carlo spikes (bright speckles in shadows). + */ + private static float smoothedIndirect(final float[] indirect, final Lightmap lightmap, final int texel) { + final int width = lightmap.width; + final int height = lightmap.height; + final int i = texel % width; + final int j = texel / width; + float sum = 0; + int count = 0; + if (i > 0 && isValid(lightmap, texel - 1)) { sum += indirect[texel - 1]; count++; } + if (i < width - 1 && isValid(lightmap, texel + 1)) { sum += indirect[texel + 1]; count++; } + if (j > 0 && isValid(lightmap, texel - width)) { sum += indirect[texel - width]; count++; } + if (j < height - 1 && isValid(lightmap, texel + width)) { sum += indirect[texel + width]; count++; } + if (count == 0) + return indirect[texel]; + return 0.5f * indirect[texel] + 0.5f * sum / count; + } + + private static boolean isValid(final Lightmap lightmap, final int texel) { + final double u = ((texel % lightmap.width) + 0.5) / lightmap.width; + final double v = ((texel / lightmap.width) + 0.5) / lightmap.height; + return u + v <= 1.0; + } + + /** Composite texel: baseColor scaled by total irradiance, clamped. */ + private static int compositePixel(final Lightmap lightmap, + final double irrR, final double irrG, final double irrB) { + final int r = Math.min(255, (int) (irrR * lightmap.baseColor.r / 255)); + final int g = Math.min(255, (int) (irrG * lightmap.baseColor.g / 255)); + final int b = Math.min(255, (int) (irrB * lightmap.baseColor.b / 255)); + return 0xFF000000 | (r << 16) | (g << 8) | b; + } + + // ------------------------------------------------------------------ + // Snapshot management + // ------------------------------------------------------------------ + + private void maybeRebuildSnapshot() { + final int version = AbstractCompositeShape.getGlobalRenderListVersion(); + final double lightSignature = lightSignature(); + if (version == lastSeenRenderListVersion && lightSignature == lastLightSignature) + return; + + final List triangles = new ArrayList<>(); + shapes.collectRenderTriangles(triangles); + + final Snapshot snap = new Snapshot(); + snap.entries = new ArrayList<>(triangles.size()); + snap.lightmaps = new ArrayList<>(); + for (final AbstractCoordinateShape triangle : triangles) { + if (triangle.vertices.size() < 3) + continue; + final TriangleBvh.Entry entry = buildEntry(triangle); + snap.entries.add(entry); + if (entry.lightmap != null) + snap.lightmaps.add(entry.lightmap); + } + if (!snap.entries.isEmpty()) + snap.bvh = new TriangleBvh(snap.entries); + snap.lights = new ArrayList<>(lightingManager.getLights()); + snap.lightIndex = new IdentityHashMap<>(); + for (int i = 0; i < snap.lights.size(); i++) + snap.lightIndex.put(snap.lights.get(i), i); + final Color ambient = lightingManager.getAmbientLight(); + snap.ambientR = ambient.r; + snap.ambientG = ambient.g; + snap.ambientB = ambient.b; + + // Flattened work list: one item per valid lightmap texel, + // one per plain polygon. + final List workItems = new ArrayList<>(); + for (final TriangleBvh.Entry entry : snap.entries) { + if (entry.lightmap != null) { + for (final int texel : entry.lightmap.validTexels) { + final WorkItem item = new WorkItem(); + item.entry = entry; + item.texel = texel; + workItems.add(item); + } + } else { + final WorkItem item = new WorkItem(); + item.entry = entry; + item.texel = -1; + workItems.add(item); + } + } + snap.workItems = workItems.toArray(new WorkItem[0]); + + snapshot = snap; + states.clear(); + lastSeenRenderListVersion = version; + lastLightSignature = lightSignature; + calmSweeps = 0; // scene changed: back to full-speed tracing + + if (DEBUG) + System.out.println("[GI] snapshot: " + snap.entries.size() + " triangles, " + + snap.workItems.length + " work items, " + + snap.lightmaps.size() + " lightmaps, " + + snap.lights.size() + " lights"); + } + + private double lightSignature() { + double signature = 0; + for (final LightSource light : lightingManager.getLights()) { + final Point3D p = light.getPosition(); + final Color c = light.getColor(); + signature += p.x + p.y + p.z + c.r + c.g + c.b + light.getIntensity() * 31.0; + } + return signature; + } + + private TriangleBvh.Entry buildEntry(final AbstractCoordinateShape triangle) { + final TriangleBvh.Entry entry = new TriangleBvh.Entry(triangle); + if (triangle instanceof LightmappedShape) + entry.lightmap = ((LightmappedShape) triangle).getLightmap(); + + final Point3D a = triangle.vertices.get(0).coordinate; + final Point3D b = triangle.vertices.get(1).coordinate; + final Point3D c = triangle.vertices.get(2).coordinate; + entry.v[0] = (float) a.x; entry.v[1] = (float) a.y; entry.v[2] = (float) a.z; + entry.v[3] = (float) b.x; entry.v[4] = (float) b.y; entry.v[5] = (float) b.z; + entry.v[6] = (float) c.x; entry.v[7] = (float) c.y; entry.v[8] = (float) c.z; + entry.centroidX = (float) ((a.x + b.x + c.x) / 3); + entry.centroidY = (float) ((a.y + b.y + c.y) / 3); + entry.centroidZ = (float) ((a.z + b.z + c.z) / 3); + entry.minX = Math.min(entry.v[0], Math.min(entry.v[3], entry.v[6])); + entry.maxX = Math.max(entry.v[0], Math.max(entry.v[3], entry.v[6])); + entry.minY = Math.min(entry.v[1], Math.min(entry.v[4], entry.v[7])); + entry.maxY = Math.max(entry.v[1], Math.max(entry.v[4], entry.v[7])); + entry.minZ = Math.min(entry.v[2], Math.min(entry.v[5], entry.v[8])); + entry.maxZ = Math.max(entry.v[2], Math.max(entry.v[5], entry.v[8])); + // Normal: right-handed cross(b-a, c-a), matching Plane.computeNormal. + final double e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z; + final double e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z; + double nx = e1y * e2z - e1z * e2y; + double ny = e1z * e2x - e1x * e2z; + double nz = e1x * e2y - e1y * e2x; + final double len = Math.sqrt(nx * nx + ny * ny + nz * nz); + if (len > 0) { + nx /= len; + ny /= len; + nz /= len; + } + entry.normal = new float[]{(float) nx, (float) ny, (float) nz}; + return entry; + } + + /** Cosine-weighted hemisphere direction around a normal. */ + private double[] cosineHemisphere(final double nx, final double ny, final double nz, + final ThreadLocalRandom random) { + final double u = random.nextDouble(); + final double v = random.nextDouble(); + final double r = Math.sqrt(u); + final double theta = 2 * Math.PI * v; + final double x = r * Math.cos(theta); + final double y = r * Math.sin(theta); + final double z = Math.sqrt(Math.max(0, 1 - u)); + + // Orthonormal basis around the normal. + final double upX = Math.abs(ny) < 0.9 ? 0 : 1; + final double upY = Math.abs(ny) < 0.9 ? 1 : 0; + double tx = upY * nz; + double ty = -upX * nz; + double tz = upX * ny - upY * nx; + final double tLen = Math.sqrt(tx * tx + ty * ty + tz * tz); + tx /= tLen; + ty /= tLen; + tz /= tLen; + final double bx = ny * tz - nz * ty; + final double by = nz * tx - nx * tz; + final double bz = nx * ty - ny * tx; + + return new double[]{ + tx * x + bx * y + nx * z, + ty * x + by * y + ny * z, + tz * x + bz * y + nz * z + }; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java new file mode 100644 index 0000000..4acf19e --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java @@ -0,0 +1,279 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.gi; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture; + +/** + * Per-triangle lightmap: a small generated texture whose texels map onto the + * triangle surface. The triangle's UVs (in texture pixel units) are + * (0,0), (width,0), (0,height), so the valid texel region is the half + * where u+v <= 1 in normalized coordinates; the other half is filled + * by mirroring to keep mipmaps and edge sampling clean. + * + *

What you trace is what you see: the composite texture the painter + * samples IS the lightmap, at native texel resolution. Shadow-edge + * smoothness comes from tracing at finer resolution (smaller + * unitsPerTexel), never from upsampling.

+ * + *

The GI system stores per-texel indirect irradiance and per-texel + * per-light visibility here, and periodically regenerates the premultiplied + * composite texture (baseColor x lighting) into the back buffer, then swaps + * it onto the rendered triangle — painters never see a half-updated + * texture.

+ * + *

Gradual convergence: what reaches the texture is never the raw + * computed irradiance but a persistent per-texel exponential moving average + * ({@link #estimateR}/{@link #estimateG}/{@link #estimateB}) over the + * complete sum ambient+direct+indirect. The estimate starts at a uniform + * medium value ({@link #INITIAL_IRRADIANCE}), so the world is visible from + * frame one; lit areas then brighten and unlit areas sink to darkness + * gradually — no black-to-lit flash is possible, since the texture can only + * move a fixed alpha fraction per composite update.

+ * + *

Threading: texel state arrays are written by GI worker threads and read + * by whoever holds the snapshot; element-wise racy access is benign for + * progressive refinement. Texture buffer swaps are volatile/atomic via + * {@link LightmappedTriangle#setTexture}.

+ */ +public class Lightmap { + + /** Visibility value: not yet computed. */ + public static final byte VISIBILITY_UNKNOWN = 0; + /** Visibility value: shadow ray reached the light. */ + public static final byte VISIBILITY_VISIBLE = 1; + /** Visibility value: shadow ray was blocked. */ + public static final byte VISIBILITY_OCCLUDED = 2; + + /** Texture size limits, power of two. */ + private static final int MIN_SIZE = 4; + private static final int MAX_SIZE = 128; + + /** + * Uniform irradiance the composite estimate starts at (light units, + * display scale 0..255): the world begins medium-lit and visible, then + * fades toward the traced solution. {@code -De3d.gi.initialIrradiance}. + */ + public static final double INITIAL_IRRADIANCE = + Double.parseDouble(System.getProperty("e3d.gi.initialIrradiance", "128")); + + public final int width; + public final int height; + + /** Unlit surface color of the triangle. */ + public final Color baseColor; + + // World mapping: p(u,v) = origin + e1*u + e2*v. + public final double originX, originY, originZ; + public final double edge1X, edge1Y, edge1Z; + public final double edge2X, edge2Y, edge2Z; + /** Unit surface normal. */ + public final double normalX, normalY, normalZ; + + /** Valid (u+v <= 1) texel indices, for round-robin sampling. */ + public final int[] validTexels; + + /** Per-texel indirect irradiance (light units, pre-albedo). */ + public final float[] indirectR; + public final float[] indirectG; + public final float[] indirectB; + + /** + * Per-texel EMA estimate of TOTAL irradiance (ambient + direct + + * indirect), the only value ever written to the composite texture. + * Initialized to {@link #INITIAL_IRRADIANCE} (uniform medium start); + * each composite update blends the freshly computed irradiance in with + * a fixed alpha, so both brightening and fading to darkness stay alive + * forever and no single-frame jump can occur. + */ + public final float[] estimateR; + public final float[] estimateG; + public final float[] estimateB; + + /** + * Per-texel sample counters. In "adaptive" alpha mode they drive the + * decaying EMA weight; in the default "fixed" mode they only mark + * first-visit texels (all-lights shadow test on the first sweep). + */ + public final short[] sampleCounts; + + /** Per-texel per-light visibility: texelCount * lightCount bytes. */ + public byte[] lightVisibility; + public int lightCount; + + /** Double-buffered composite textures; the triangle shows one, GI fills the other. */ + private final Texture[] buffers = new Texture[2]; + private int shownBuffer; + + /** The triangle currently displaying this lightmap (for texture swaps). */ + public volatile LightmappedTriangle owner; + + /** Round-robin light cursor for shadow sampling (GI threads only). */ + public int nextLight; + + /** + * Creates a lightmap for a triangle. + * + * @param a first vertex (UV 0,0) + * @param b second vertex (UV 1,0) + * @param c third vertex (UV 0,1) + * @param baseColor unlit surface color + * @param unitsPerTexel world units per lightmap texel (resolution knob) + * @param normalX unit normal x + * @param normalY unit normal y + * @param normalZ unit normal z + */ + public Lightmap(final Point3D a, final Point3D b, final Point3D c, + final Color baseColor, final double unitsPerTexel, + final double normalX, final double normalY, final double normalZ) { + this.baseColor = baseColor; + originX = a.x; + originY = a.y; + originZ = a.z; + edge1X = b.x - a.x; + edge1Y = b.y - a.y; + edge1Z = b.z - a.z; + edge2X = c.x - a.x; + edge2Y = c.y - a.y; + edge2Z = c.z - a.z; + this.normalX = normalX; + this.normalY = normalY; + this.normalZ = normalZ; + + final double len1 = Math.sqrt(edge1X * edge1X + edge1Y * edge1Y + edge1Z * edge1Z); + final double len2 = Math.sqrt(edge2X * edge2X + edge2Y * edge2Y + edge2Z * edge2Z); + width = powerOfTwo(len1 / unitsPerTexel); + height = powerOfTwo(len2 / unitsPerTexel); + + indirectR = new float[width * height]; + indirectG = new float[width * height]; + indirectB = new float[width * height]; + estimateR = new float[width * height]; + estimateG = new float[width * height]; + estimateB = new float[width * height]; + java.util.Arrays.fill(estimateR, (float) INITIAL_IRRADIANCE); + java.util.Arrays.fill(estimateG, (float) INITIAL_IRRADIANCE); + java.util.Arrays.fill(estimateB, (float) INITIAL_IRRADIANCE); + sampleCounts = new short[width * height]; + + final int[] valid = new int[width * height]; + int count = 0; + for (int j = 0; j < height; j++) + for (int i = 0; i < width; i++) { + final double u = (i + 0.5) / width; + final double v = (j + 0.5) / height; + if (u + v <= 1.0) + valid[count++] = j * width + i; + } + validTexels = new int[count]; + System.arraycopy(valid, 0, validTexels, 0, count); + + // Both buffers start at the uniform medium INITIAL_IRRADIANCE: + // the world is visible from frame one and fades toward the traced + // solution (lit areas brighten, unlit areas sink to darkness). + buffers[0] = createTexture(); + buffers[1] = createTexture(); + } + + private static int powerOfTwo(final double size) { + int result = MIN_SIZE; + while (result < size && result < MAX_SIZE) + result <<= 1; + return result; + } + + private Texture createTexture() { + final Texture texture = new Texture(width, height, 0); + final int r = Math.min(255, (int) (INITIAL_IRRADIANCE * baseColor.r / 255)); + final int g = Math.min(255, (int) (INITIAL_IRRADIANCE * baseColor.g / 255)); + final int b = Math.min(255, (int) (INITIAL_IRRADIANCE * baseColor.b / 255)); + final int pixel = 0xFF000000 | (r << 16) | (g << 8) | b; + java.util.Arrays.fill(texture.primaryBitmap.pixels, pixel); + return texture; + } + + /** + * World position of a texel center. + * + * @param texel texel index (j * width + i) + * @param out receives x, y, z + */ + public void texelWorldPosition(final int texel, final double[] out) { + final double u = ((texel % width) + 0.5) / width; + final double v = ((texel / width) + 0.5) / height; + out[0] = originX + edge1X * u + edge2X * v; + out[1] = originY + edge1Y * u + edge2Y * v; + out[2] = originZ + edge1Z * u + edge2Z * v; + } + + /** + * Texel index nearest to a world point on the triangle plane. + * + * @param px world x + * @param py world y + * @param pz world z + * @return texel index, clamped into the texture + */ + public int texelAt(final double px, final double py, final double pz) { + final double dx = px - originX; + final double dy = py - originY; + final double dz = pz - originZ; + final double d11 = edge1X * edge1X + edge1Y * edge1Y + edge1Z * edge1Z; + final double d22 = edge2X * edge2X + edge2Y * edge2Y + edge2Z * edge2Z; + final double d12 = edge1X * edge2X + edge1Y * edge2Y + edge1Z * edge2Z; + final double dp1 = dx * edge1X + dy * edge1Y + dz * edge1Z; + final double dp2 = dx * edge2X + dy * edge2Y + dz * edge2Z; + final double denom = d11 * d22 - d12 * d12; + if (denom < 1e-12) + return 0; + final double u = (dp1 * d22 - dp2 * d12) / denom; + final double v = (dp2 * d11 - dp1 * d12) / denom; + int i = (int) (u * width); + int j = (int) (v * height); + if (i < 0) i = 0; + if (i >= width) i = width - 1; + if (j < 0) j = 0; + if (j >= height) j = height - 1; + return j * width + i; + } + + /** + * Ensures the per-texel visibility array matches the light count. + * Called from GI threads during sampling. + * + * @param lights number of lights in the snapshot + */ + public void ensureLightCapacity(final int lights) { + if (lightVisibility == null || lightCount != lights) { + lightVisibility = new byte[width * height * lights]; + lightCount = lights; + } + } + + /** + * The texture the triangle should show right now. + * + * @return the front composite texture + */ + public Texture shownTexture() { + return buffers[shownBuffer]; + } + + /** + * The texture GI should write the next composite into. + * + * @return the back composite texture + */ + public Texture backTexture() { + return buffers[1 - shownBuffer]; + } + + /** Flips the buffers after the back texture has been regenerated. */ + public void swapBuffers() { + shownBuffer = 1 - shownBuffer; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedShape.java new file mode 100644 index 0000000..bdf50bd --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedShape.java @@ -0,0 +1,20 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.gi; + +/** + * A shape carrying a {@link Lightmap}. The global illumination system + * detects this interface in its scene snapshot and samples GI per lightmap + * texel instead of per polygon. + */ +public interface LightmappedShape { + + /** + * Returns the lightmap for this shape. + * + * @return the lightmap + */ + Lightmap getLightmap(); +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedTriangle.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedTriangle.java new file mode 100644 index 0000000..e285b97 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedTriangle.java @@ -0,0 +1,65 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.gi; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.math.Vertex; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle; + +/** + * A textured triangle whose texture is a GI-generated lightmap composite + * (baseColor x lighting). UVs are fixed at (0,0), (width,0), (0,height) + * (engine UVs are in texture pixel units): the whole triangle is covered by + * its own lightmap, valid region u+v <= 1 in normalized coordinates. + * + *

Shading via {@code LightingManager} does not apply — all light (ambient, + * direct with shadows, indirect) lives in the composite texture, which the + * GI system regenerates and swaps in every few sweeps.

+ */ +public class LightmappedTriangle extends TexturedTriangle implements LightmappedShape { + + private final Lightmap lightmap; + + /** + * Creates a lightmapped triangle. + * + * @param a first vertex (shared coordinate reference is fine) + * @param b second vertex + * @param c third vertex + * @param baseColor unlit surface color + * @param unitsPerTexel world units per lightmap texel + * @param normalX unit normal x + * @param normalY unit normal y + * @param normalZ unit normal z + */ + public LightmappedTriangle(final Point3D a, final Point3D b, final Point3D c, + final Color baseColor, final double unitsPerTexel, + final double normalX, final double normalY, final double normalZ) { + super(new Vertex(a, new Point2D(0, 0)), + new Vertex(b, new Point2D(1, 0)), + new Vertex(c, new Point2D(0, 1)), + null); + lightmap = new Lightmap(a, b, c, baseColor, unitsPerTexel, + normalX, normalY, normalZ); + lightmap.owner = this; + + // UVs are in PRIMARY TEXTURE PIXELS in this engine (multiplication + // factor scales them into the selected mip level), so the triangle + // corners map to the lightmap corners directly. + vertices.get(0).textureCoordinate = new Point2D(0, 0); + vertices.get(1).textureCoordinate = new Point2D(lightmap.width, 0); + vertices.get(2).textureCoordinate = new Point2D(0, lightmap.height); + refreshTextureDistance(); + + setTexture(lightmap.shownTexture()); + } + + @Override + public Lightmap getLightmap() { + return lightmap; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java new file mode 100644 index 0000000..5e099ae --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java @@ -0,0 +1,231 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.gi; + +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape; + +import java.util.List; + +/** + * Bounding volume hierarchy over world-space triangles for fast ray queries. + * Used by the global illumination system; deliberately separate from the + * voxel octree (which serves voxel tracing and stays untouched). + * + *

Build: top-down median split along the longest AABB axis. + * Queries: nearest-hit for bounce rays, any-hit with early out for shadow + * rays. Intersection is Möller–Trumbore, two-sided (walls are single quads + * that must occlude from both sides). Not thread-safe to build, safe to + * query concurrently once published via a volatile/atomic reference.

+ */ +public class TriangleBvh { + + /** One ray-traced triangle with cached data. */ + public static class Entry { + public final AbstractCoordinateShape polygon; + /** Lightmap when the polygon is a {@link LightmappedShape}, else null. */ + public Lightmap lightmap; + /** Triangle vertices, world space: x0,y0,z0, x1,y1,z1, x2,y2,z2. */ + public final float[] v = new float[9]; + public float centroidX, centroidY, centroidZ; + public float minX, minY, minZ, maxX, maxY, maxZ; + /** Unit surface normal, world space. */ + public volatile float[] normal; + + public Entry(final AbstractCoordinateShape polygon) { + this.polygon = polygon; + } + } + + /** Nearest-hit query result. */ + public static class Hit { + public Entry entry; + public double t; + public double pointX, pointY, pointZ; + } + + private static class Node { + double minX, minY, minZ, maxX, maxY, maxZ; + Node left, right; + Entry[] entries; // leaf only + } + + private static final int LEAF_SIZE = 4; + private static final double EPSILON = 0.01; + + private final Node root; + + /** + * Builds the tree over the given triangle entries. + * + * @param entries triangles to index (must not be empty) + */ + public TriangleBvh(final List entries) { + root = build(entries.toArray(new Entry[0]), 0, entries.size()); + } + + private Node build(final Entry[] entries, final int from, final int to) { + final Node node = new Node(); + double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE, minZ = Double.MAX_VALUE; + double maxX = -Double.MAX_VALUE, maxY = -Double.MAX_VALUE, maxZ = -Double.MAX_VALUE; + for (int i = from; i < to; i++) { + final Entry e = entries[i]; + minX = Math.min(minX, e.minX); maxX = Math.max(maxX, e.maxX); + minY = Math.min(minY, e.minY); maxY = Math.max(maxY, e.maxY); + minZ = Math.min(minZ, e.minZ); maxZ = Math.max(maxZ, e.maxZ); + } + node.minX = minX; node.minY = minY; node.minZ = minZ; + node.maxX = maxX; node.maxY = maxY; node.maxZ = maxZ; + + final int count = to - from; + if (count <= LEAF_SIZE) { + node.entries = new Entry[count]; + System.arraycopy(entries, from, node.entries, 0, count); + return node; + } + + // Split along the longest axis at the median centroid. + final double dx = maxX - minX, dy = maxY - minY, dz = maxZ - minZ; + final int axis = (dx >= dy && dx >= dz) ? 0 : (dy >= dz ? 1 : 2); + java.util.Arrays.sort(entries, from, to, (a, b) -> { + final double ca = axis == 0 ? a.centroidX : axis == 1 ? a.centroidY : a.centroidZ; + final double cb = axis == 0 ? b.centroidX : axis == 1 ? b.centroidY : b.centroidZ; + return Double.compare(ca, cb); + }); + final int mid = from + count / 2; + node.left = build(entries, from, mid); + node.right = build(entries, mid, to); + return node; + } + + /** + * Finds the nearest triangle hit along the ray, or null. + * + * @param hit reusable result object, filled on hit + * @return true on hit + */ + public boolean nearest(final double ox, final double oy, final double oz, + final double dx, final double dy, final double dz, + final Hit hit) { + hit.t = Double.MAX_VALUE; + hit.entry = null; + nearestNode(root, ox, oy, oz, dx, dy, dz, hit); + if (hit.entry == null) + return false; + hit.pointX = ox + dx * hit.t; + hit.pointY = oy + dy * hit.t; + hit.pointZ = oz + dz * hit.t; + return true; + } + + private void nearestNode(final Node node, final double ox, final double oy, final double oz, + final double dx, final double dy, final double dz, final Hit hit) { + if (!rayBox(node, ox, oy, oz, dx, dy, dz, hit.t)) + return; + + if (node.entries != null) { + for (final Entry e : node.entries) { + final double t = rayTriangle(ox, oy, oz, dx, dy, dz, e.v); + if (t > EPSILON && t < hit.t) { + hit.t = t; + hit.entry = e; + } + } + return; + } + nearestNode(node.left, ox, oy, oz, dx, dy, dz, hit); + nearestNode(node.right, ox, oy, oz, dx, dy, dz, hit); + } + + /** + * Any-hit shadow query: is the segment from the origin to + * {@code maxT} along the direction blocked? + * + * @return true if any triangle intersects the segment + */ + public boolean occluded(final double ox, final double oy, final double oz, + final double dx, final double dy, final double dz, + final double maxT) { + return occludedNode(root, ox, oy, oz, dx, dy, dz, maxT); + } + + private boolean occludedNode(final Node node, final double ox, final double oy, final double oz, + final double dx, final double dy, final double dz, final double maxT) { + if (!rayBox(node, ox, oy, oz, dx, dy, dz, maxT)) + return false; + + if (node.entries != null) { + for (final Entry e : node.entries) { + final double t = rayTriangle(ox, oy, oz, dx, dy, dz, e.v); + if (t > EPSILON && t < maxT) + return true; + } + return false; + } + return occludedNode(node.left, ox, oy, oz, dx, dy, dz, maxT) + || occludedNode(node.right, ox, oy, oz, dx, dy, dz, maxT); + } + + /** Slab test: does the ray hit the node box before limitT? */ + private boolean rayBox(final Node node, final double ox, final double oy, final double oz, + final double dx, final double dy, final double dz, final double limitT) { + double tMin = 0, tMax = limitT; + + double t1 = (node.minX - ox) / dx; + double t2 = (node.maxX - ox) / dx; + if (Double.isNaN(t1)) t1 = Double.NEGATIVE_INFINITY; + if (Double.isNaN(t2)) t2 = Double.POSITIVE_INFINITY; + tMin = Math.max(tMin, Math.min(t1, t2)); + tMax = Math.min(tMax, Math.max(t1, t2)); + + t1 = (node.minY - oy) / dy; + t2 = (node.maxY - oy) / dy; + if (Double.isNaN(t1)) t1 = Double.NEGATIVE_INFINITY; + if (Double.isNaN(t2)) t2 = Double.POSITIVE_INFINITY; + tMin = Math.max(tMin, Math.min(t1, t2)); + tMax = Math.min(tMax, Math.max(t1, t2)); + + t1 = (node.minZ - oz) / dz; + t2 = (node.maxZ - oz) / dz; + if (Double.isNaN(t1)) t1 = Double.NEGATIVE_INFINITY; + if (Double.isNaN(t2)) t2 = Double.POSITIVE_INFINITY; + tMin = Math.max(tMin, Math.min(t1, t2)); + tMax = Math.min(tMax, Math.max(t1, t2)); + + return tMax >= tMin && tMax > EPSILON; + } + + /** Möller–Trumbore, two-sided. Returns t or -1. */ + private double rayTriangle(final double ox, final double oy, final double oz, + final double dx, final double dy, final double dz, + final float[] v) { + final double e1x = v[3] - v[0], e1y = v[4] - v[1], e1z = v[5] - v[2]; + final double e2x = v[6] - v[0], e2y = v[7] - v[1], e2z = v[8] - v[2]; + + final double px = dy * e2z - dz * e2y; + final double py = dz * e2x - dx * e2z; + final double pz = dx * e2y - dy * e2x; + + final double det = e1x * px + e1y * py + e1z * pz; + if (det > -1e-12 && det < 1e-12) + return -1; + + final double invDet = 1.0 / det; + final double tx = ox - v[0], ty = oy - v[1], tz = oz - v[2]; + final double u = (tx * px + ty * py + tz * pz) * invDet; + if (u < 0 || u > 1) + return -1; + + final double qx = ty * e1z - tz * e1y; + final double qy = tz * e1x - tx * e1z; + final double qz = tx * e1y - ty * e1x; + final double vv = (dx * qx + dy * qy + dz * qz) * invDet; + if (vv < 0 || u + vv > 1) + return -1; + + final double t = (e2x * qx + e2y * qy + e2z * qz) * invDet; + return t > 0 ? t : -1; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/package-info.java new file mode 100644 index 0000000..daa9df9 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/package-info.java @@ -0,0 +1,22 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Progressive CPU global illumination. + * + *

{@link eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination} + * runs Monte Carlo ray casting on dedicated low-priority threads against a + * {@link eu.svjatoslav.aukio.e3d.renderer.raster.gi.TriangleBvh} built over + * the rendered polygons, and feeds per-polygon direct-light visibility + * (shadows) and indirect irradiance (bounced light) into the shading path + * through + * {@link eu.svjatoslav.aukio.e3d.renderer.raster.lighting.GiLightProvider}. + * Rendering itself never casts rays; illumination converges progressively + * and adapts to scene changes over time.

+ * + *

GI is strictly opt-in: enable it with + * {@code viewPanel.enableGlobalIllumination()}.

+ */ +package eu.svjatoslav.aukio.e3d.renderer.raster.gi; diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/GiLightProvider.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/GiLightProvider.java new file mode 100644 index 0000000..f30cf3f --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/GiLightProvider.java @@ -0,0 +1,50 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.lighting; + +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; + +/** + * Optional provider of global-illumination data for + * {@link LightingManager}. When a provider is installed, the per-polygon + * lighting computation additionally: + * + *
    + *
  • asks the provider whether each light source is occluded from the + * polygon (direct-light shadows), and
  • + *
  • adds the provider's indirect (bounced light) contribution.
  • + *
+ * + *

Implementations are called from parallel render-pool threads: all + * methods must be thread-safe, fast and allocation-free. Providers are + * expected to answer from progressively updated caches.

+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination the progressive CPU global illumination system + */ +public interface GiLightProvider { + + /** + * Tells whether a light source currently has an unobstructed path to + * the polygon. Until the provider has computed the answer, it should + * return {@code true} (light visible): shadows then fade in gradually + * instead of popping out. + * + * @param polygon the shaded polygon + * @param light the light source being evaluated + * @return true if the light reaches the polygon + */ + boolean isLightVisible(SolidPolygon polygon, LightSource light); + + /** + * Adds the polygon's indirect (bounced light) contribution to an + * already computed direct-lighting color, in place. + * + * @param polygon the shaded polygon + * @param baseColor the polygon's unlit color (for albedo scaling) + * @param result the direct-lighted color to augment (modified in place) + */ + void addIndirectLight(SolidPolygon polygon, Color baseColor, Color result); +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightSource.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightSource.java new file mode 100644 index 0000000..2bde5e6 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightSource.java @@ -0,0 +1,136 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.lighting; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; + +/** + * Represents a light source in the 3D scene with position, color, and intensity. + * + *

Light sources emit colored light that illuminates polygons based on their + * orientation relative to the light. The intensity of illumination follows the + * Lambert cosine law - surfaces facing the light receive full intensity, while + * surfaces at an angle receive proportionally less light.

+ * + *

Usage example:

+ *
{@code
+ * // Create a yellow light source at position (100, -50, 200)
+ * LightSource light = new LightSource(
+ *     new Point3D(100, -50, 200),
+ *     Color.YELLOW,
+ *     1.5
+ * );
+ *
+ * // Move the light source
+ * light.setPosition(new Point3D(0, 0, 300));
+ *
+ * // Change the light color
+ * light.setColor(new Color(255, 100, 50));
+ *
+ * // Adjust intensity
+ * light.setIntensity(2.0);
+ * }
+ * + * @see LightingManager manages multiple light sources and calculates shading + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon + */ +public class LightSource { + + /** + * Position of the light source in 3D world space. + */ + private Point3D position; + + /** + * Color of the light emitted by this source. + */ + private Color color; + + /** + * Intensity multiplier for this light source. + * Values greater than 1.0 make the light brighter, values less than 1.0 make it dimmer. + * High intensity values can cause surfaces to appear white (clamped at 255). + */ + private double intensity; + + /** + * Creates a new light source at the specified position with the given color and intensity. + * + * @param position the position of the light in world space + * @param color the color of the light + * @param intensity the intensity multiplier (1.0 = normal brightness) + */ + public LightSource(final Point3D position, final Color color, final double intensity) { + this.position = position; + this.color = color; + this.intensity = intensity; + } + + /** + * Creates a new light source at the specified position with the given color. + * Default intensity is 1.0. + * + * @param position the position of the light in world space + * @param color the color of the light + */ + public LightSource(final Point3D position, final Color color) { + this(position, color, 1.0); + } + + /** + * Returns the color of this light source. + * + * @return the light color + */ + public Color getColor() { + return color; + } + + /** + * Returns the intensity multiplier of this light source. + * + * @return the intensity multiplier + */ + public double getIntensity() { + return intensity; + } + + /** + * Returns the position of this light source. + * + * @return the position in world space + */ + public Point3D getPosition() { + return position; + } + + /** + * Sets the color of this light source. + * + * @param color the new light color + */ + public void setColor(final Color color) { + this.color = color; + } + + /** + * Sets the intensity multiplier of this light source. + * + * @param intensity the new intensity multiplier (1.0 = normal brightness) + */ + public void setIntensity(final double intensity) { + this.intensity = intensity; + } + + /** + * Sets the position of this light source. + * + * @param position the new position in world space + */ + public void setPosition(final Point3D position) { + this.position = position; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.java new file mode 100644 index 0000000..6ec3972 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.java @@ -0,0 +1,278 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.lighting; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; + +import java.util.ArrayList; +import java.util.List; + +/** + * Manages light sources in the scene and calculates lighting for polygons. + * + *

This class implements flat shading using the Lambert cosine law. For each + * polygon face, it calculates the surface normal and determines how much light + * each source contributes based on the angle between the normal and the light + * direction.

+ * + *

The lighting calculation considers:

+ *
    + *
  • Distance from polygon center to each light source
  • + *
  • Angle between surface normal and light direction
  • + *
  • Color and intensity of each light source
  • + *
+ * + *

Usage example:

+ *
{@code
+ * LightingManager lighting = new LightingManager();
+ *
+ * // Add light sources
+ * lighting.addLight(new LightSource(new Point3D(100, -50, 200), Color.YELLOW));
+ * lighting.addLight(new LightSource(new Point3D(-100, 50, 200), Color.BLUE));
+ *
+ * // Set ambient light (base illumination)
+ * lighting.setAmbientLight(new Color(30, 30, 30));
+ *
+ * // Calculate shaded color for a polygon (reusing result Color to avoid allocation)
+ * Color result = new Color();
+ * lighting.computeLighting(polygonCenter, surfaceNormal, baseColor, result);
+ * }
+ * + * @see LightSource represents a single light source + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon + */ +public class LightingManager { + + private final List lights = new ArrayList<>(); + private Color ambientLight = new Color(10, 10, 10); + + /** + * Optional global-illumination provider. When set, the polygon-aware + * {@link #computeLighting(SolidPolygon, Point3D, Point3D, Color, Color)} + * overload adds shadow tests and indirect light. Null by default: + * lighting behaves exactly as before (no occlusion, no indirect). + */ + private volatile GiLightProvider giProvider; + + /** + * Creates a new lighting manager with no light sources. + */ + public LightingManager() { + } + + /** + * Adds a light source to the scene. + * + * @param light the light source to add + */ + public void addLight(final LightSource light) { + lights.add(light); + } + + /** + * Computes lighting for a polygon and stores the result in an existing Color. + * + *

This method avoids allocation by reusing an existing Color instance. + * Safe to call from multiple threads on the same result Color - the computation + * is deterministic (same polygon, same lights = same result).

+ * + * @param polygonCenter the center point of the polygon in world space + * @param normal the surface normal vector (should be normalized) + * @param baseColor the original color of the polygon + * @param result the Color to receive the shaded result (modified in place) + */ + public void computeLighting(final Point3D polygonCenter, + final Point3D normal, + final Color baseColor, + final Color result) { + // Start with ambient light contribution + int totalR = ambientLight.r; + int totalG = ambientLight.g; + int totalB = ambientLight.b; + + // Calculate contribution from each light source + for (final LightSource light : lights) { + final Point3D lightPos = light.getPosition(); + final Color lightColor = light.getColor(); + final double lightIntensity = light.getIntensity(); + + // Calculate vector from polygon to light + final double lightDirX = lightPos.x - polygonCenter.x; + final double lightDirY = lightPos.y - polygonCenter.y; + final double lightDirZ = lightPos.z - polygonCenter.z; + + // Normalize the light direction + final double lightDist = Math.sqrt( + lightDirX * lightDirX + + lightDirY * lightDirY + + lightDirZ * lightDirZ + ); + + if (lightDist < 0.0001) + continue; + + final double invLightDist = 1.0 / lightDist; + final double normLightDirX = lightDirX * invLightDist; + final double normLightDirY = lightDirY * invLightDist; + final double normLightDirZ = lightDirZ * invLightDist; + + // Calculate dot product (Lambert cosine law) + final double dotProduct = normal.x * normLightDirX + + normal.y * normLightDirY + + normal.z * normLightDirZ; + + // Only add light if surface faces the light + if (dotProduct > 0) { + // Apply distance attenuation (inverse square law, simplified) + final double attenuation = 1.0 / (1.0 + 0.0001 * lightDist * lightDist); + final double intensity = dotProduct * attenuation * lightIntensity; + + // Add light color contribution + totalR += (int) (lightColor.r * intensity); + totalG += (int) (lightColor.g * intensity); + totalB += (int) (lightColor.b * intensity); + } + } + + // Clamp values to valid range and apply to base color + final int r = Math.min(255, (totalR * baseColor.r) / 255); + final int g = Math.min(255, (totalG * baseColor.g) / 255); + final int b = Math.min(255, (totalB * baseColor.b) / 255); + + result.set(r, g, b, baseColor.a); + } + + /** + * GI-aware lighting computation: identical to + * {@link #computeLighting(Point3D, Point3D, Color, Color)} when no + * {@link GiLightProvider} is installed. With a provider, lights occluded + * from the polygon are skipped (direct shadows) and the provider's + * indirect contribution is added on top. + * + * @param polygon the polygon being shaded (provider key) + * @param polygonCenter the center point of the polygon in world space + * @param normal the surface normal vector (should be normalized) + * @param baseColor the original color of the polygon + * @param result the Color to receive the shaded result (modified in place) + */ + public void computeLighting(final SolidPolygon polygon, + final Point3D polygonCenter, + final Point3D normal, + final Color baseColor, + final Color result) { + final GiLightProvider gi = giProvider; + if (gi == null) { + computeLighting(polygonCenter, normal, baseColor, result); + return; + } + + int totalR = ambientLight.r; + int totalG = ambientLight.g; + int totalB = ambientLight.b; + + for (final LightSource light : lights) { + if (!gi.isLightVisible(polygon, light)) + continue; + + final Point3D lightPos = light.getPosition(); + final Color lightColor = light.getColor(); + final double lightIntensity = light.getIntensity(); + + final double lightDirX = lightPos.x - polygonCenter.x; + final double lightDirY = lightPos.y - polygonCenter.y; + final double lightDirZ = lightPos.z - polygonCenter.z; + + final double lightDist = Math.sqrt( + lightDirX * lightDirX + + lightDirY * lightDirY + + lightDirZ * lightDirZ + ); + + if (lightDist < 0.0001) + continue; + + final double invLightDist = 1.0 / lightDist; + final double dotProduct = normal.x * lightDirX * invLightDist + + normal.y * lightDirY * invLightDist + + normal.z * lightDirZ * invLightDist; + + if (dotProduct > 0) { + final double attenuation = 1.0 / (1.0 + 0.0001 * lightDist * lightDist); + final double intensity = dotProduct * attenuation * lightIntensity; + + totalR += (int) (lightColor.r * intensity); + totalG += (int) (lightColor.g * intensity); + totalB += (int) (lightColor.b * intensity); + } + } + + final int r = Math.min(255, (totalR * baseColor.r) / 255); + final int g = Math.min(255, (totalG * baseColor.g) / 255); + final int b = Math.min(255, (totalB * baseColor.b) / 255); + + result.set(r, g, b, baseColor.a); + gi.addIndirectLight(polygon, baseColor, result); + } + + /** + * Installs (or clears, with null) the global-illumination provider used + * by the polygon-aware computeLighting overload. + * + * @param giProvider the provider, or null to disable GI + */ + public void setGiProvider(final GiLightProvider giProvider) { + this.giProvider = giProvider; + } + + /** + * Returns the installed global-illumination provider, or null. + * + * @return the GI provider or null + */ + public GiLightProvider getGiProvider() { + return giProvider; + } + + /** + * Returns the ambient light color. + * + * @return the ambient light color + */ + public Color getAmbientLight() { + return ambientLight; + } + + /** + * Sets the ambient light color for the scene. + * + *

Ambient light provides base illumination that affects all surfaces + * equally, regardless of their orientation.

+ * + * @param ambientLight the ambient light color + */ + public void setAmbientLight(final Color ambientLight) { + this.ambientLight = ambientLight; + } + + /** + * Returns all light sources in the scene. + * + * @return list of light sources + */ + public List getLights() { + return lights; + } + + /** + * Removes a light source from the scene. + * + * @param light the light source to remove + */ + public void removeLight(final LightSource light) { + lights.remove(light); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/package-info.java new file mode 100644 index 0000000..ce0963f --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/package-info.java @@ -0,0 +1,21 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Lighting system for flat-shaded polygon rendering. + * + *

This package implements a simple Lambertian lighting model for shading + * solid polygons based on their surface normals relative to light sources.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager} - Manages lights and calculates shading
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightSource} - Represents a point light source
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager + */ + +package eu.svjatoslav.aukio.e3d.renderer.raster.lighting; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/package-info.java new file mode 100755 index 0000000..a7e5fee --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/package-info.java @@ -0,0 +1,26 @@ +/** + * Rasterization-based real-time software renderer for the Aukio 3D engine. + * + *

This package provides a complete rasterization pipeline that renders 3D scenes + * to a 2D pixel buffer using traditional approaches:

+ *
    + *
  • Wireframe rendering - lines and wireframe shapes
  • + *
  • Solid polygon rendering - filled polygons with flat shading
  • + *
  • Textured polygon rendering - polygons with texture mapping and mipmap support
  • + *
  • Depth sorting - back-to-front painter's algorithm using Z-index ordering
  • + *
+ * + *

Key classes in this package:

+ *
    + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection} - root container for all 3D shapes in a scene
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator} - collects and depth-sorts shapes for rendering
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.Color} - RGBA color representation with predefined constants
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic basic shape primitives (lines, polygons) + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite composite shapes (boxes, grids, text) + * @see eu.svjatoslav.aukio.e3d.renderer.raster.texture texture and mipmap support + */ + +package eu.svjatoslav.aukio.e3d.renderer.raster; + diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.java new file mode 100644 index 0000000..70e466b --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.java @@ -0,0 +1,643 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes; + +import eu.svjatoslav.aukio.e3d.geometry.Box; +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.math.TransformStack; +import eu.svjatoslav.aukio.e3d.math.Vertex; +import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Base class for shapes defined by a list of vertex coordinates. + * + *

This is the foundation for all primitive renderable shapes such as lines, + * solid polygons, and textured polygons. Each shape has a list of vertices + * ({@link Vertex} objects) that define its geometry in 3D space.

+ * + *

During each render frame, the {@link #transform} method projects all vertices + * from world space to screen space. If all vertices are visible (in front of the camera), + * the shape is queued in the {@link RenderAggregator} for depth-sorted painting via + * the {@link #paint} method.

+ * + *

Creating a custom coordinate shape:

+ *
{@code
+ * public class Triangle extends AbstractCoordinateShape {
+ *     private final Color color;
+ *
+ *     public Triangle(Point3D p1, Point3D p2, Point3D p3, Color color) {
+ *         super(new Vertex(p1), new Vertex(p2), new Vertex(p3));
+ *         this.color = color;
+ *     }
+ *
+ *     public void paint(RenderingContext ctx) {
+ *         // Custom painting logic using ctx.graphics and
+ *         // vertices.get(i).transformedCoordinate for screen positions
+ *     }
+ * }
+ * }
+ * + * @see AbstractShape the parent class for all shapes + * @see Vertex wraps a 3D coordinate with its transformed (screen-space) position + * @see RenderAggregator collects and depth-sorts shapes before painting + */ +public abstract class AbstractCoordinateShape extends AbstractShape { + + /** + * Global counter used to assign unique IDs to shapes, ensuring deterministic + * rendering order for shapes at the same depth. + */ + private static final AtomicInteger lastShapeId = new AtomicInteger(); + + /** + * Unique identifier for this shape instance, used as a tiebreaker when + * sorting shapes with identical Z-depth values. + */ + public final int shapeId; + + /** + * The vertex coordinates that define this shape's geometry. + * Each vertex contains both the original world-space coordinate and + * a transformed screen-space coordinate computed during {@link #transform}. + * + *

Stored as a mutable list to support CSG operations that modify + * polygon vertices in place (splitting, flipping).

+ */ + public final List vertices; + + /** + * Average Z-depth of this shape in screen space after transformation, + * per buffer slot. Used by the {@link RenderAggregator} to sort shapes + * back-to-front for correct painter's algorithm rendering. + * Access via {@link #getZ(RenderingContext)} / {@link #getZ(int)}. + */ + private double onScreenZ0; + private double onScreenZ1; + private double onScreenZ2; + + /** + * Screen-space Y bounds of this shape after transformation, per buffer + * slot, expanded by {@link #getScreenYMargin(RenderingContext)} so they + * cover every pixel {@link #paint} can touch. Valid only in frames where + * this shape was queued for rendering. Used by {@link RenderAggregator} + * to bin shapes per paint tile by overlap. + */ + private double onScreenMinY0; + private double onScreenMaxY0; + private double onScreenMinY1; + private double onScreenMaxY1; + private double onScreenMinY2; + private double onScreenMaxY2; + + /** + * Screen-space X bounds of this shape after transformation, per buffer + * slot, expanded by {@link #getScreenXMargin(RenderingContext)}. + */ + private double onScreenMinX0; + private double onScreenMaxX0; + private double onScreenMinX1; + private double onScreenMaxX1; + private double onScreenMinX2; + private double onScreenMaxX2; + + /** + * Writes this shape's screen state for one buffer slot in one call. + * Used by bulk transform paths ({@code TriangleMeshBlock}) whose + * triangles carry no per-vertex objects: the handle exposes the same + * per-slot values an object-backed transform would have written, so + * the Z comparator and tile binning keep reading plain fields. + * + * @param slot buffer slot (0, 1 or 2) + * @param z average camera-space Z + * @param minY screen-space minimum Y (with paint margins) + * @param maxY screen-space maximum Y + * @param minX screen-space minimum X (with paint margins) + * @param maxX screen-space maximum X + */ + protected final void setSlotScreenState(final int slot, final double z, + final double minY, final double maxY, + final double minX, final double maxX) { + if (slot == 0) { + onScreenZ0 = z; + onScreenMinY0 = minY; + onScreenMaxY0 = maxY; + onScreenMinX0 = minX; + onScreenMaxX0 = maxX; + } else if (slot == 1) { + onScreenZ1 = z; + onScreenMinY1 = minY; + onScreenMaxY1 = maxY; + onScreenMinX1 = minX; + onScreenMaxX1 = maxX; + } else { + onScreenZ2 = z; + onScreenMinY2 = minY; + onScreenMaxY2 = maxY; + onScreenMinX2 = minX; + onScreenMaxX2 = maxX; + } + } + + /** + * Near-plane-clipped vertex loop for this shape, per buffer slot. + * Null when the shape was NOT clipped this frame (all original vertices + * in front of the near plane) or when the shape was culled. When set, + * {@link #paint} must iterate THIS list instead of {@link #vertices}: + * the original vertices contain behind-camera positions whose projected + * screen coordinates are garbage (divide by z <= 0 flips signs). + * + *

The list holds a mix of original {@link Vertex} objects (their + * per-slot state was filled by the normal transform) and freshly + * created intersection vertices (filled via + * {@link Vertex#setCameraSpaceCoordinate}). Stored per slot so the + * double-buffered pipeline can transform frame N+1 into slot B while + * frame N is still painting from slot A.

+ */ + private List clippedVertices0; + + /** + * Cached subpixel-cull verdict (see the size check in + * {@link #transform}): while {@link #subpixelCulledEpoch} matches the + * context's epoch, transform returns immediately without any setup. + */ + private boolean subpixelCulled; + private int subpixelCulledEpoch = -1; + private List clippedVertices1; + private List clippedVertices2; + + /** + * Creates a shape with the specified number of vertices, each initialized + * to the origin (0, 0, 0). + * + * @param vertexCount the number of vertices in this shape + */ + public AbstractCoordinateShape(final int vertexCount) { + vertices = new ArrayList<>(vertexCount); + for (int i = 0; i < vertexCount; i++) { + vertices.add(new Vertex()); + } + shapeId = lastShapeId.getAndIncrement(); + } + + /** + * Creates a shape from the given vertices. + * + * @param vertices the vertices defining this shape's geometry + */ + public AbstractCoordinateShape(final Vertex... vertices) { + this.vertices = new ArrayList<>(Arrays.asList(vertices)); + shapeId = lastShapeId.getAndIncrement(); + } + + /** + * Creates a shape from a list of vertices. + * + * @param vertices the list of vertices defining this shape's geometry + */ + public AbstractCoordinateShape(final List vertices) { + this.vertices = vertices; + shapeId = lastShapeId.getAndIncrement(); + } + + /** + * Returns the average Z-depth of this shape in screen space for the + * context's buffer slot. + * + * @param renderingContext the rendering context (selects the buffer slot) + * @return the average Z-depth value, used for depth sorting + */ + public double getZ(final RenderingContext renderingContext) { + final int slot = renderingContext.vertexSlot; + return slot == 0 ? onScreenZ0 : slot == 1 ? onScreenZ1 : onScreenZ2; + } + + /** + * Returns the average Z-depth of this shape for an explicit buffer slot. + * + * @param slot buffer slot (0 or 1) + * @return the average Z-depth value, used for depth sorting + */ + public double getZ(final int slot) { + return slot == 0 ? onScreenZ0 : slot == 1 ? onScreenZ1 : onScreenZ2; + } + + /** + * Screen-space minimum Y bound (pixels) for the given buffer slot. + * + * @param slot buffer slot (0 or 1) + * @return minimum Y this shape's paint can touch + */ + public double onScreenMinY(final int slot) { + return slot == 0 ? onScreenMinY0 : slot == 1 ? onScreenMinY1 : onScreenMinY2; + } + + /** + * Screen-space maximum Y bound (pixels) for the given buffer slot. + * + * @param slot buffer slot (0 or 1) + * @return maximum Y this shape's paint can touch + */ + public double onScreenMaxY(final int slot) { + return slot == 0 ? onScreenMaxY0 : slot == 1 ? onScreenMaxY1 : onScreenMaxY2; + } + + /** + * Screen-space minimum X bound (pixels) for the given buffer slot. + * + * @param slot buffer slot (0 or 1) + * @return minimum X this shape's paint can touch + */ + public double onScreenMinX(final int slot) { + return slot == 0 ? onScreenMinX0 : slot == 1 ? onScreenMinX1 : onScreenMinX2; + } + + /** + * Screen-space maximum X bound (pixels) for the given buffer slot. + * + * @param slot buffer slot (0 or 1) + * @return maximum X this shape's paint can touch + */ + public double onScreenMaxX(final int slot) { + return slot == 0 ? onScreenMaxX0 : slot == 1 ? onScreenMaxX1 : onScreenMaxX2; + } + + /** + * Sets the average Z-depth for the given buffer slot. + * + * @param slot buffer slot (0 or 1) + * @param z average Z-depth value + */ + public void setZ(final int slot, final double z) { + if (slot == 0) + onScreenZ0 = z; + else if (slot == 1) + onScreenZ1 = z; + else + onScreenZ2 = z; + } + + /** + * Returns the axis-aligned bounding box computed from vertex coordinates. + * + *

The bounding box encompasses all vertices in this shape, computed + * by finding the minimum and maximum coordinates along each axis.

+ * + *

Caching: The bounding box is cached after first computation. + * If vertices change, call {@link #invalidateBounds()} before calling + * this method to trigger recomputation.

+ * + * @return the axis-aligned bounding box in local coordinates + */ + @Override + public Box getBoundingBox() { + if (cachedBoundingBox == null && !vertices.isEmpty()) { + // Compute bounds from vertex coordinates + double minX = Double.MAX_VALUE; + double maxX = -Double.MAX_VALUE; + double minY = Double.MAX_VALUE; + double maxY = -Double.MAX_VALUE; + double minZ = Double.MAX_VALUE; + double maxZ = -Double.MAX_VALUE; + + for (final Vertex vertex : vertices) { + final Point3D coord = vertex.coordinate; + minX = Math.min(minX, coord.x); + maxX = Math.max(maxX, coord.x); + minY = Math.min(minY, coord.y); + maxY = Math.max(maxY, coord.y); + minZ = Math.min(minZ, coord.z); + maxZ = Math.max(maxZ, coord.z); + } + + cachedBoundingBox = new Box( + new Point3D(minX, minY, minZ), + new Point3D(maxX, maxY, maxZ) + ); + } + return cachedBoundingBox != null ? cachedBoundingBox : super.getBoundingBox(); + } + + /** + * Translates all vertices by the specified offsets. + * + *

This method moves the entire shape by modifying each vertex's + * world-space coordinate. It also invalidates the cached bounding box + * so that frustum culling uses the correct bounds after movement.

+ * + *

Usage example:

+ *
{@code
+     * // Move shape 10 units up (Y decreases in Aukio 3D's coordinate system)
+     * shape.translate(0, -10, 0);
+     *
+     * // Move shape diagonally
+     * shape.translate(5, 0, 5);
+     * }
+ * + * @param dx offset along the X axis (positive = right) + * @param dy offset along the Y axis (positive = down, negative = up) + * @param dz offset along the Z axis (positive = away from camera) + */ + public void translate(final double dx, final double dy, final double dz) { + for (final Vertex vertex : vertices) { + vertex.coordinate.x += dx; + vertex.coordinate.y += dy; + vertex.coordinate.z += dz; + } + invalidateBounds(); + } + + /** + * Paints this shape onto the rendering context's pixel buffer. + * + *

This method is called after all shapes have been transformed and sorted + * by depth. Implementations should use the transformed screen-space coordinates + * from {@link Vertex#transformedCoordinate} to draw pixels.

+ * + * @param renderBuffer the rendering context containing the pixel buffer and graphics context + */ + public abstract void paint(RenderingContext renderBuffer); + + /** + * Extra screen-space Y distance beyond the vertex bounds that + * {@link #paint} can touch. Shapes whose paint output extends past the + * vertex positions (thick lines, billboards, text glyphs) must override + * this so tile binning does not drop them from tiles they + * partially overlap. + * + * @param renderingContext the rendering context (provides projection + * parameters for size computation) + * @return the Y margin in screen pixels (0 = vertex bounds are exact) + */ + protected double getScreenYMargin(final RenderingContext renderingContext) { + return 0; + } + + /** + * Extra screen-space X distance beyond the vertex bounds that + * {@link #paint} can touch. Same contract as + * {@link #getScreenYMargin(RenderingContext)}, for the X axis. + * + * @param renderingContext the rendering context (provides projection + * parameters for size computation) + * @return the X margin in screen pixels (0 = vertex bounds are exact) + */ + protected double getScreenXMargin(final RenderingContext renderingContext) { + return 0; + } + + /** + * {@inheritDoc} + * + *

Transforms all vertices to screen space by applying the current transform stack. + * If ALL vertices are behind the near plane the shape is culled; if SOME are, + * the vertex loop is clipped against the near plane (see + * {@link #clipToNearPlane(RenderingContext)}) and the clipped loop — not the + * original vertices — is queued for rendering. Computes the average Z-depth + * and screen bounds from the active (possibly clipped) vertices and queues + * this shape for rendering.

+ */ + @Override + public void transform(final TransformStack transforms, + final RenderAggregator aggregator, + final RenderingContext renderingContext) { + + // Cached subpixel-cull verdict: skip the entire setup (vertex + // transforms, clipping, bounds) while the verdict is fresh. The + // epoch advances on significant camera movement, so a culled + // shape is re-evaluated whenever it could have grown on screen. + if (subpixelCulled + && subpixelCulledEpoch == renderingContext.subpixelCullingEpoch) + return; + + final int slot = renderingContext.vertexSlot; + final double near = renderingContext.nearPlaneDistance; + boolean anyBehind = false; + boolean allBehind = true; + + // Indexed loops, not enhanced-for: an iterator per shape per + // frame was a measurable share of render-time allocation + // (~26 MB sampled over 8 frames at the FO4 spawn view). + for (int vi = 0; vi < vertices.size(); vi++) { + final Vertex geometryPoint = vertices.get(vi); + geometryPoint.calculateLocationRelativeToViewer(transforms, renderingContext); + if (geometryPoint.transformedCoordinate(renderingContext).z > near) + allBehind = false; + else + anyBehind = true; + } + + if (allBehind) { + setClippedVertices(slot, null); + return; + } + + final List active; + if (anyBehind) { + active = clipToNearPlane(renderingContext); + // Degenerate sliver (fewer points than a renderable primitive): + // a line needs 2, a polygon needs 3. + if (active.size() < Math.min(vertices.size(), 3)) { + setClippedVertices(slot, null); + return; + } + setClippedVertices(slot, active); + } else { + setClippedVertices(slot, null); + active = vertices; + } + + double accumulatedZ = 0; + double minY = Double.POSITIVE_INFINITY; + double maxY = Double.NEGATIVE_INFINITY; + double minX = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY; + + for (int vi = 0; vi < active.size(); vi++) { + final Vertex geometryPoint = active.get(vi); + + final Point3D transformed = geometryPoint.transformedCoordinate(renderingContext); + final Point2D onScreen = geometryPoint.onScreenCoordinate(renderingContext); + + accumulatedZ += transformed.z; + + if (onScreen.y < minY) + minY = onScreen.y; + if (onScreen.y > maxY) + maxY = onScreen.y; + if (onScreen.x < minX) + minX = onScreen.x; + if (onScreen.x > maxX) + maxX = onScreen.x; + } + + // Subpixel culling: raw projected span (before paint margins) + // below the threshold on both axes means the shape cannot cover + // even a fraction of one pixel. Cache the verdict so frames with + // a barely-moved camera skip this shape's whole transform setup. + final double cullThreshold = renderingContext.subpixelCullingThreshold; + if (cullThreshold > 0 + && maxX - minX < cullThreshold + && maxY - minY < cullThreshold) { + subpixelCulled = true; + subpixelCulledEpoch = renderingContext.subpixelCullingEpoch; + return; + } + subpixelCulled = false; + + final double z = accumulatedZ / active.size(); + final double marginY = getScreenYMargin(renderingContext); + final double loY = minY - marginY; + final double hiY = maxY + marginY; + final double marginX = getScreenXMargin(renderingContext); + final double loX = minX - marginX; + final double hiX = maxX + marginX; + if (slot == 0) { + onScreenZ0 = z; + onScreenMinY0 = loY; + onScreenMaxY0 = hiY; + onScreenMinX0 = loX; + onScreenMaxX0 = hiX; + } else if (slot == 1) { + onScreenZ1 = z; + onScreenMinY1 = loY; + onScreenMaxY1 = hiY; + onScreenMinX1 = loX; + onScreenMaxX1 = hiX; + } else { + onScreenZ2 = z; + onScreenMinY2 = loY; + onScreenMaxY2 = hiY; + onScreenMinX2 = loX; + onScreenMaxX2 = hiX; + } + // Screen-space culling: composite frustum culling skips whole + // invisible cells, but a visible cell still queues every one of + // its triangles — including those behind the camera or past the + // viewport edge. Bounds (with paint margins) are already computed + // above, so drop shapes that cannot touch the viewport: on dense + // scenes this shrinks the sort/paint queues several-fold. The + // test mirrors RenderAggregator tile binning, which uses the same + // margined bounds, so nothing paintable is ever dropped. + if (hiX < renderingContext.renderMinX + || loX >= renderingContext.renderMaxX + || hiY < renderingContext.renderMinY + || loY >= renderingContext.renderMaxY) { + return; + } + aggregator.queueShapeForRendering(this); + } + + /** + * Returns the near-plane-clipped vertex loop for the context's buffer + * slot, or null if this shape was not clipped (or was culled) this + * frame. Paint implementations must use this list when non-null. + * + * @param renderingContext the rendering context (selects the buffer slot) + * @return the clipped vertex loop, or null + */ + public List clippedVertices(final RenderingContext renderingContext) { + final int slot = renderingContext.vertexSlot; + return slot == 0 ? clippedVertices0 + : slot == 1 ? clippedVertices1 : clippedVertices2; + } + + /** + * Stores the clipped vertex loop for the given buffer slot. + * + * @param slot buffer slot (0, 1 or 2) + * @param clipped the clipped loop, or null to clear + */ + private void setClippedVertices(final int slot, final List clipped) { + if (slot == 0) + clippedVertices0 = clipped; + else if (slot == 1) + clippedVertices1 = clipped; + else + clippedVertices2 = clipped; + } + + /** + * Clips this shape's vertex loop against the near plane + * (camera-space z == {@code renderingContext.nearPlaneDistance}), + * Sutherland-Hodgman style. Assumes at least one vertex is in front and + * at least one is behind (checked by {@link #transform}). + * + *

For every edge crossing the plane a new intersection vertex is + * created with linearly interpolated position, UV and normal, and its + * per-slot screen position is computed immediately via + * {@link Vertex#setCameraSpaceCoordinate}. In-front original vertices + * pass through by reference.

+ * + *

A convex N-gon crossing the plane clips to a single contiguous + * loop of 3..N+1 vertices (a triangle can become a quad). Two-vertex + * shapes (lines) are clipped as a single open edge, yielding the + * in-front endpoint plus the intersection point.

+ * + * @param renderingContext the rendering context (provides the near + * distance and projection parameters) + * @return the clipped vertex loop in original winding order + */ + private List clipToNearPlane(final RenderingContext renderingContext) { + final double near = renderingContext.nearPlaneDistance; + final int n = vertices.size(); + // Lines (2 vertices) form one open edge, not a closed loop: + // iterating n edges would emit the intersection point twice. + final int edgeCount = n == 2 ? 1 : n; + final List result = new ArrayList<>(n + 1); + + for (int i = 0; i < edgeCount; i++) { + final Vertex current = vertices.get(i); + final Vertex next = vertices.get((i + 1) % n); + final Point3D c = current.transformedCoordinate(renderingContext); + final Point3D p = next.transformedCoordinate(renderingContext); + final boolean currentIn = c.z > near; + final boolean nextIn = p.z > near; + + if (currentIn) + result.add(current); + + if (currentIn != nextIn) { + final double t = (near - c.z) / (p.z - c.z); + result.add(interpolateAtPlane(current, next, c, p, t, renderingContext)); + } + } + return result; + } + + /** + * Creates the vertex where edge a->b crosses the near plane. + * Position, texture coordinate and normal are interpolated with the + * same parameter t (linear in 3D, which is exactly what + * perspective-correct texturing expects of a point on the edge). + */ + private static Vertex interpolateAtPlane(final Vertex a, final Vertex b, + final Point3D ca, final Point3D cb, + final double t, + final RenderingContext renderingContext) { + final double x = ca.x + (cb.x - ca.x) * t; + final double y = ca.y + (cb.y - ca.y) * t; + final double z = ca.z + (cb.z - ca.z) * t; + + final Point2D uv = (a.textureCoordinate != null && b.textureCoordinate != null) + ? new Point2D( + a.textureCoordinate.x + (b.textureCoordinate.x - a.textureCoordinate.x) * t, + a.textureCoordinate.y + (b.textureCoordinate.y - a.textureCoordinate.y) * t) + : null; + + final Vertex clipped = new Vertex(new Point3D(x, y, z), uv); + if (a.normal != null && b.normal != null) + clipped.normal = a.normal.interpolate(b.normal, t); + clipped.setCameraSpaceCoordinate(x, y, z, renderingContext); + return clipped; + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractShape.java new file mode 100644 index 0000000..f6e81a3 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractShape.java @@ -0,0 +1,157 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes; + +import eu.svjatoslav.aukio.e3d.geometry.Box; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController; +import eu.svjatoslav.aukio.e3d.math.TransformStack; +import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator; + +/** + * Base class for all renderable shapes in the Aukio 3D engine. + * + *

Every shape that can be rendered must extend this class and implement the + * {@link #transform(TransformStack, RenderAggregator, RenderingContext)} method, + * which projects the shape from world space into screen space during each render frame.

+ * + *

Shapes can optionally have a {@link MouseInteractionController} attached to receive + * mouse click and hover events when the user interacts with the shape in the 3D view.

+ * + *

Shape hierarchy overview:

+ *
+ * AbstractShape
+ *   +-- AbstractCoordinateShape   (shapes with vertex coordinates: lines, polygons)
+ *   +-- AbstractCompositeShape    (groups of sub-shapes: boxes, grids, text canvases)
+ * 
+ * + * @see AbstractCoordinateShape for shapes defined by vertex coordinates + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape for compound shapes + * @see MouseInteractionController for handling mouse events on shapes + */ +public abstract class AbstractShape { + + /** + * Default constructor for abstract shape. + */ + public AbstractShape() { + } + + /** + * Optional controller that receives mouse interaction events (click, enter, exit) + * when the user interacts with this shape in the 3D view. + * Set to {@code null} if mouse interaction is not needed. + */ + public MouseInteractionController mouseInteractionController; + + /** + * Cached bounding box in local coordinates. + * Lazily computed on first call to {@link #getBoundingBox()}. + * Subclasses should set this to null when geometry changes to trigger recomputation. + */ + protected Box cachedBoundingBox = null; + + /** + * Returns the axis-aligned bounding box for this shape in local coordinates. + * + *

The bounding box is used for frustum culling to determine if the shape + * is potentially visible before expensive vertex transformations.

+ * + *

Conservative default: Returns a very large box that ensures + * the shape is always considered visible. Subclasses should override to + * provide tight bounds computed from their geometry.

+ * + *

Caching: The bounding box is cached after first computation. + * If geometry changes, call {@link #invalidateBounds()} to trigger + * recomputation on next call.

+ * + * @return the axis-aligned bounding box in local coordinates + */ + public Box getBoundingBox() { + if (cachedBoundingBox == null) { + // Conservative default: very large box (shape always visible) + cachedBoundingBox = new Box( + new Point3D(-1e10, -1e10, -1e10), + new Point3D(1e10, 1e10, 1e10) + ); + } + return cachedBoundingBox; + } + + /** + * Invalidates the cached bounding box, forcing recomputation on next call + * to {@link #getBoundingBox()}. + * + *

Call this method whenever the shape's geometry changes to ensure + * frustum culling uses up-to-date bounds. This is critical for shapes + * that move or deform after creation.

+ * + *

Usage example:

+ *
{@code
+     * // After modifying vertex coordinates directly:
+     * vertex.coordinate.translate(0, 10, 0);
+     * shape.invalidateBounds();
+     *
+     * // Or use translate() on AbstractCoordinateShape which handles this automatically
+     * }
+ */ + public void invalidateBounds() { + cachedBoundingBox = null; + } + + /** + * Assigns a mouse interaction controller to this shape. + * + *

Example usage:

+ *
{@code
+     * shape.setMouseInteractionController(new MouseInteractionController() {
+     *     public boolean mouseClicked(int button) {
+     *         System.out.println("Shape clicked!");
+     *         return true;
+     *     }
+     *     public boolean mouseEntered() { return false; }
+     *     public boolean mouseExited() { return false; }
+     * });
+     * }
+ * + * @param mouseInteractionController the controller to handle mouse events, + * or {@code null} to disable mouse interaction + */ + public void setMouseInteractionController( + final MouseInteractionController mouseInteractionController) { + this.mouseInteractionController = mouseInteractionController; + } + + /** + * Transforms this shape from world space to screen space and queues it for rendering. + * + *

This method is called once per frame for each shape in the scene. Implementations + * should apply the current transform stack to their vertices, compute screen-space + * coordinates, and if the shape is visible, add it to the {@link RenderAggregator} + * for depth-sorted painting.

+ * + * @param transforms the current stack of transforms (world-to-camera transformations) + * @param aggregator collects transformed shapes for depth-sorted rendering + * @param renderingContext provides frame dimensions, graphics context, and frame metadata + */ + public abstract void transform(final TransformStack transforms, + final RenderAggregator aggregator, + final RenderingContext renderingContext); + + /** + * Estimated cost of transforming this shape, in arbitrary units where + * a leaf primitive counts 1. Composites override this with their total + * subtree weight. Used by the parallel transform fork to decide where + * splitting pays off. + * + * @param renderingContext the rendering context (frame identity for caching) + * @return transform weight, always at least 1 + */ + public int getTransformWeight(final RenderingContext renderingContext) { + return 1; + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/Billboard.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/Billboard.java new file mode 100644 index 0000000..1924793 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/Billboard.java @@ -0,0 +1,249 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.math.Vertex; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.TextureBitmap; + +/** + * A billboard: a texture that always faces the viewer. + * + *

This class implements the "billboard" rendering technique where the texture + * remains oriented towards the camera regardless of 3D position. The visible size + * is calculated based on distance from viewer (z-coordinate) and scale factor.

+ * + *

Texture mapping algorithm:

+ *
    + *
  1. Calculates screen coverage based on perspective
  2. + *
  3. Clips to viewport boundaries
  4. + *
  5. Maps texture pixels to screen pixels using proportional scaling
  6. + *
+ * + * @see GlowingPoint a billboard with a circular gradient texture + * @see Texture + */ +public class Billboard extends AbstractCoordinateShape { + + private static final double SCALE_MULTIPLIER = 0.005; + + /** + * The texture to display on this billboard. + */ + public final Texture texture; + + /** + * Scale factor for the billboard's visible size. + *
    + *
  • 0 means infinitely small
  • + *
  • 1 is recommended to maintain texture sharpness
  • + *
+ */ + private double scale; + + /** + * Creates a billboard at the specified position with the given scale and texture. + * + * @param point the 3D position of the billboard center + * @param scale the scale factor (1.0 is recommended for sharpness) + * @param texture the texture to display + */ + public Billboard(final Point3D point, final double scale, + final Texture texture) { + super(new Vertex(point)); + this.texture = texture; + setScale(scale); + } + + /** + * The screen-aligned quad extends this far above and below the center + * vertex, well beyond the (single-vertex) Y range. + */ + @Override + protected double getScreenYMargin(final RenderingContext renderingContext) { + return (renderingContext.width * scale * texture.primaryBitmap.height) + / vertices.get(0).transformedCoordinate(renderingContext).z; + } + + /** + * Same story on the X axis: the quad extends half its screen width + * to either side of the center vertex. + */ + @Override + protected double getScreenXMargin(final RenderingContext renderingContext) { + return (renderingContext.width * scale * texture.primaryBitmap.width) + / vertices.get(0).transformedCoordinate(renderingContext).z; + } + + /** + * Renders this billboard to the screen. + * + *

The billboard is rendered as a screen-aligned quad centered on the projected + * position. The size is computed based on distance and scale factor.

+ * + *

Performance optimization: Uses fixed-point incremental stepping to avoid + * per-pixel division, and inlines alpha blending to avoid method call overhead. + * This provides 50-70% better performance than the previous division-based approach.

+ * + * @param targetRenderingArea the rendering context containing the pixel buffer + */ + @Override + public void paint(final RenderingContext targetRenderingArea) { + // Sprites don't participate in depth (overlay semantics) — + // paint only in the alpha pass. + if (targetRenderingArea.depthPass == 1) + return; + + // distance from camera/viewer to center of the texture + final double z = vertices.get(0).transformedCoordinate(targetRenderingArea).z; + + // compute forward oriented texture visible distance from center + final double visibleHorizontalDistanceFromCenter = (targetRenderingArea.width + * scale * texture.primaryBitmap.width) / z; + + final double visibleVerticalDistanceFromCenter = (targetRenderingArea.width + * scale * texture.primaryBitmap.height) / z; + + // compute visible pixel density, and get appropriate bitmap + final double scale = (visibleHorizontalDistanceFromCenter * 2) + / texture.primaryBitmap.width; + + final TextureBitmap textureBitmap = texture.getMipmapForScale(scale); + + final Point2D onScreenCoordinate = vertices.get(0).onScreenCoordinate(targetRenderingArea); + + // compute Y + final int onScreenUncappedYStart = (int) (onScreenCoordinate.y - visibleVerticalDistanceFromCenter); + final int onScreenUncappedYEnd = (int) (onScreenCoordinate.y + visibleVerticalDistanceFromCenter); + final int onScreenUncappedHeight = onScreenUncappedYEnd - onScreenUncappedYStart; + + int onScreenCappedYStart = onScreenUncappedYStart; + int onScreenCappedYEnd = onScreenUncappedYEnd; + + // cap Y to upper screen border + if (onScreenCappedYStart < 0) + onScreenCappedYStart = 0; + + // cap Y to lower screen border + if (onScreenCappedYEnd > targetRenderingArea.height) + onScreenCappedYEnd = targetRenderingArea.height; + + // clamp to render Y bounds + onScreenCappedYStart = Math.max(onScreenCappedYStart, targetRenderingArea.renderMinY); + onScreenCappedYEnd = Math.min(onScreenCappedYEnd, targetRenderingArea.renderMaxY); + if (onScreenCappedYStart >= onScreenCappedYEnd) + return; + + // compute X + final int onScreenUncappedXStart = (int) (onScreenCoordinate.x - visibleHorizontalDistanceFromCenter); + final int onScreenUncappedXEnd = (int) (onScreenCoordinate.x + visibleHorizontalDistanceFromCenter); + final int onScreenUncappedWidth = onScreenUncappedXEnd - onScreenUncappedXStart; + + // cap X to left viewport border (supports stereo per-eye clipping) + int onScreenCappedXStart = onScreenUncappedXStart; + if (onScreenCappedXStart < targetRenderingArea.renderMinX) + onScreenCappedXStart = targetRenderingArea.renderMinX; + + // cap X to right viewport border (supports stereo per-eye clipping) + int onScreenCappedXEnd = onScreenUncappedXEnd; + if (onScreenCappedXEnd > targetRenderingArea.renderMaxX) + onScreenCappedXEnd = targetRenderingArea.renderMaxX; + + if (onScreenCappedXStart >= onScreenCappedXEnd) + return; + + final int[] targetPixels = targetRenderingArea.pixels; + final int[] sourcePixels = textureBitmap.pixels; + final int textureWidth = textureBitmap.width; + final int textureHeight = textureBitmap.height; + final int targetWidth = targetRenderingArea.width; + + // Fixed-point (16.16) texture stepping values - eliminates per-pixel division + // Source X advances by textureWidth / onScreenUncappedWidth per screen pixel + final int sourceXStep = (textureWidth << 16) / onScreenUncappedWidth; + // Source Y advances by textureHeight / onScreenUncappedHeight per screen scanline + final int sourceYStep = (textureHeight << 16) / onScreenUncappedHeight; + + // Initialize source Y position (fixed-point) at the first capped scanline + int sourceY = ((onScreenCappedYStart - onScreenUncappedYStart) * sourceYStep); + + for (int y = onScreenCappedYStart; y < onScreenCappedYEnd; y++) { + + // Convert fixed-point Y to integer scanline base address + final int sourceYInt = sourceY >> 16; + final int scanlineBase = sourceYInt * textureWidth; + + // Initialize source X position (fixed-point) at the first capped pixel + int sourceX = ((onScreenCappedXStart - onScreenUncappedXStart) * sourceXStep); + + int targetOffset = (y * targetWidth) + onScreenCappedXStart; + + for (int x = onScreenCappedXStart; x < onScreenCappedXEnd; x++) { + + // Convert fixed-point X to integer and compute source address + final int sourceAddress = scanlineBase + (sourceX >> 16); + + // Inline alpha blending from TextureBitmap.drawPixel() + final int sourcePixel = sourcePixels[sourceAddress]; + final int srcAlpha = (sourcePixel >> 24) & 0xff; + + if (srcAlpha != 0) { + if (srcAlpha == 255) { + // Fully opaque - direct copy + targetPixels[targetOffset] = sourcePixel; + } else { + // Semi-transparent - alpha blend + final int backgroundAlpha = 255 - srcAlpha; + + final int srcR = ((sourcePixel >> 16) & 0xff) * srcAlpha; + final int srcG = ((sourcePixel >> 8) & 0xff) * srcAlpha; + final int srcB = (sourcePixel & 0xff) * srcAlpha; + + final int destPixel = targetPixels[targetOffset]; + final int destR = (destPixel >> 16) & 0xff; + final int destG = (destPixel >> 8) & 0xff; + final int destB = destPixel & 0xff; + + final int r = ((destR * backgroundAlpha) + srcR) >> 8; + final int g = ((destG * backgroundAlpha) + srcG) >> 8; + final int b = ((destB * backgroundAlpha) + srcB) >> 8; + + targetPixels[targetOffset] = (r << 16) | (g << 8) | b; + } + } + + // Advance source X using fixed-point addition (no division!) + sourceX += sourceXStep; + targetOffset++; + } + + // Advance source Y using fixed-point addition (no division!) + sourceY += sourceYStep; + } + } + + /** + * Sets the scale factor for this billboard. + * + * @param scale the scale factor (1.0 is recommended for sharpness) + */ + public void setScale(final double scale) { + this.scale = scale * SCALE_MULTIPLIER; + } + + /** + * Returns the 3D position of this billboard. + * + * @return the center position in world coordinates + */ + public Point3D getLocation() { + return vertices.get(0).coordinate; + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/GlowingPoint.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/GlowingPoint.java new file mode 100644 index 0000000..3b30912 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/GlowingPoint.java @@ -0,0 +1,114 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture; + +import java.util.Collections; +import java.util.Set; +import java.util.WeakHashMap; + +import static java.lang.Math.pow; +import static java.lang.Math.sqrt; + +/** + * A glowing 3D point rendered with a circular gradient texture. + * + *

This class creates and reuses textures for glowing points of the same color. + * The texture is a circle with an alpha gradient from center to edge, ensuring + * a consistent visual appearance regardless of viewing angle.

+ * + *

Texture sharing: Glowing points of the same color share textures + * to reduce memory usage. Textures are garbage collected via WeakHashMap when + * no longer referenced.

+ * + * @see Billboard the parent class + * @see Color + */ +public class GlowingPoint extends Billboard { + + private static final int TEXTURE_RESOLUTION_PIXELS = 100; + + /** + * Set of all existing glowing points, used for texture sharing. + */ + private static final Set glowingPoints = Collections.newSetFromMap(new WeakHashMap<>()); + private final Color color; + + /** + * Creates a glowing point at the specified position with the given size and color. + * + * @param point the 3D position of the point + * @param pointSize the visible size of the point + * @param color the color of the glow + */ + public GlowingPoint(final Point3D point, final double pointSize, + final Color color) { + super(point, computeScale(pointSize), getTexture(color)); + this.color = color; + + synchronized (glowingPoints) { + glowingPoints.add(this); + } + } + + + /** + * Computes the scale factor from point size. + * + * @param pointSize the desired visible size + * @return the scale factor for the billboard + */ + private static double computeScale(double pointSize) { + return pointSize / ((double) (TEXTURE_RESOLUTION_PIXELS / 50f)); + } + + /** + * Returns a texture for a glowing point of the given color. + * + *

Attempts to reuse an existing texture from another glowing point of the + * same color. If none exists, creates a new texture.

+ * + * @param color the color of the glow + * @return a texture with a circular alpha gradient + */ + private static Texture getTexture(final Color color) { + // attempt to reuse texture from existing glowing point of the same color + synchronized (glowingPoints) { + for (GlowingPoint glowingPoint : glowingPoints) + if (color.equals(glowingPoint.color)) + return glowingPoint.texture; + } + + // existing texture not found, creating new one + return createTexture(color); + } + + /** + * Creates a texture for a glowing point of the given color. + * The texture is a circle with a gradient from transparent to the given color. + */ + private static Texture createTexture(final Color color) { + final Texture texture = new Texture(TEXTURE_RESOLUTION_PIXELS, TEXTURE_RESOLUTION_PIXELS, 1); + int halfResolution = TEXTURE_RESOLUTION_PIXELS / 2; + + for (int x = 0; x < TEXTURE_RESOLUTION_PIXELS; x++) + for (int y = 0; y < TEXTURE_RESOLUTION_PIXELS; y++) { + final int distanceFromCenter = (int) sqrt(pow(halfResolution - x, 2) + pow(halfResolution - y, 2)); + + int alpha = 255 - ((270 * distanceFromCenter) / halfResolution); + if (alpha < 0) + alpha = 0; + + texture.primaryBitmap.pixels[texture.primaryBitmap.getAddress(x, y)] = + (alpha << 24) | (color.r << 16) | (color.g << 8) | color.b; + } + + return texture; + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.java new file mode 100644 index 0000000..d327c38 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.java @@ -0,0 +1,465 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.math.Vertex; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape; + +import java.util.List; + + +/** + * A 3D line segment with perspective-correct width and alpha blending. + *

+ * This class represents a line between two 3D points, rendered with a specified + * width that adjusts based on perspective (distance from the viewer). + * The line is drawn using interpolators to handle edge cases and alpha blending for + * transparency effects. + *

+ * The rendering algorithm: + * 1. For thin lines (below a threshold), draws single-pixel lines with alpha + * adjustment based on perspective. + * 2. For thicker lines, creates four interpolators to define the line's + * rectangular area and fills it scanline by scanline. + *

+ * Note: The width is scaled by the LINE_WIDTH_MULTIPLIER and adjusted based on + * the distance from the viewer (z-coordinate) to maintain a consistent visual size. + */ +public class Line extends AbstractCoordinateShape { + + private static final double MINIMUM_WIDTH_THRESHOLD = 1; + + private static final double LINE_WIDTH_MULTIPLIER = 0.2d; + + /** + * Thread-local interpolators for line rendering. + * Each rendering thread gets its own array to avoid race conditions. + */ + private static final ThreadLocal LINE_INTERPOLATORS = + ThreadLocal.withInitial(() -> { + final LineInterpolator[] arr = new LineInterpolator[4]; + for (int i = 0; i < arr.length; i++) { + arr[i] = new LineInterpolator(); + } + return arr; + }); + + /** + * width of the line. + */ + public final double width; + + /** + * Color of the line. + */ + public Color color; + + /** + * Creates a copy of an existing line with cloned coordinates and color. + * + * @param parentLine the line to copy + */ + public Line(final Line parentLine) { + this(parentLine.vertices.get(0).coordinate.clone(), + parentLine.vertices.get(1).coordinate.clone(), + new Color(parentLine.color), parentLine.width); + } + + /** + * Creates a line between two points with the specified color and width. + * + * @param point1 the starting point of the line + * @param point2 the ending point of the line + * @param color the color of the line + * @param width the width of the line in world units + */ + public Line(final Point3D point1, final Point3D point2, final Color color, + final double width) { + + super( + new Vertex(point1), + new Vertex(point2) + ); + + this.color = color; + this.width = width; + } + + /** + * Draws a horizontal scanline between two interpolators with alpha blending. + * + * @param line1 the left edge interpolator + * @param line2 the right edge interpolator + * @param y the Y coordinate of the scanline + * @param renderBuffer the rendering context to draw into + */ + private void drawHorizontalLine(final LineInterpolator line1, + final LineInterpolator line2, final int y, + final RenderingContext renderBuffer) { + + int x1 = line1.getX(y); + int x2 = line2.getX(y); + + double d1 = line1.getD(); + double d2 = line2.getD(); + + if (x1 > x2) { + final int tmp = x1; + x1 = x2; + x2 = tmp; + + final double tmp2 = d1; + d1 = d2; + d2 = tmp2; + } + + final int unclippedWidth = x2 - x1; + final double dinc = (d2 - d1) / unclippedWidth; + + if (x1 < renderBuffer.renderMinX) { + d1 += (dinc * (renderBuffer.renderMinX - x1)); + x1 = renderBuffer.renderMinX; + } + + // x2 is exclusive (loop paints [x1, x2)): clamp to renderMaxX, + // not renderMaxX-1, or the rightmost tile column stays unpainted + if (x2 >= renderBuffer.renderMaxX) + x2 = renderBuffer.renderMaxX; + + final int drawnWidth = x2 - x1; + + int offset = (y * renderBuffer.width) + x1; + final int[] pixels = renderBuffer.pixels; + + final int lineAlpha = color.a; + + final int colorR = color.r; + final int colorG = color.g; + final int colorB = color.b; + + for (int i = 0; i < drawnWidth; i++) { + + final double alphaMultiplier = 1d - Math.abs(d1); + + final int realLineAlpha = (int) (lineAlpha * alphaMultiplier); + final int backgroundAlpha = 255 - realLineAlpha; + + final int dest = pixels[offset]; + final int destR = (dest >> 16) & 0xff; + final int destG = (dest >> 8) & 0xff; + final int destB = dest & 0xff; + + final int newR = ((destR * backgroundAlpha) + (colorR * realLineAlpha)) >> 8; + final int newG = ((destG * backgroundAlpha) + (colorG * realLineAlpha)) >> 8; + final int newB = ((destB * backgroundAlpha) + (colorB * realLineAlpha)) >> 8; + + pixels[offset++] = (newR << 16) | (newG << 8) | newB; + + d1 += dinc; + } + + } + + /** + * Draws a thin line as single pixels with alpha-adjusted color. + * Used for lines that appear thin on screen (below minimum width threshold). + * + * @param buffer the rendering context to draw into + * @param alpha the alpha value for the entire line + */ + private void drawSinglePixelHorizontalLine(final RenderingContext buffer, + final int alpha, + final Point2D onScreenPoint1, + final Point2D onScreenPoint2) { + int xStart = (int) onScreenPoint1.x; + int xEnd = (int) onScreenPoint2.x; + + int lineHeight; + int yBase; + + if (xStart > xEnd) { + final int tmp = xStart; + xStart = xEnd; + xEnd = tmp; + lineHeight = (int) (onScreenPoint1.y - onScreenPoint2.y); + yBase = (int) onScreenPoint2.y; + } else { + yBase = (int) onScreenPoint1.y; + lineHeight = (int) (onScreenPoint2.y - onScreenPoint1.y); + } + + final int lineWidth = xEnd - xStart; + if (lineWidth == 0) + return; + + final int[] pixels = buffer.pixels; + final int backgroundAlpha = 255 - alpha; + + final int redWithAlpha = color.r * alpha; + final int greenWithAlpha = color.g * alpha; + final int blueWithAlpha = color.b * alpha; + + for (int relativeX = 0; relativeX <= lineWidth; relativeX++) { + final int x = xStart + relativeX; + + if ((x >= buffer.renderMinX) && (x < buffer.renderMaxX)) { + + final int y = yBase + ((relativeX * lineHeight) / lineWidth); + if ((y >= buffer.renderMinY) && (y < buffer.renderMaxY)) { + if ((y >= 0) && (y < buffer.height)) { + int offset = (y * buffer.width) + x; + + final int dest = pixels[offset]; + final int destR = (dest >> 16) & 0xff; + final int destG = (dest >> 8) & 0xff; + final int destB = dest & 0xff; + + final int newR = ((destR * backgroundAlpha) + redWithAlpha) >> 8; + final int newG = ((destG * backgroundAlpha) + greenWithAlpha) >> 8; + final int newB = ((destB * backgroundAlpha) + blueWithAlpha) >> 8; + + pixels[offset] = (newR << 16) | (newG << 8) | newB; + } + } + } + } + + } + + /** + * Draws a thin vertical line as single pixels with alpha-adjusted color. + * Used for lines that appear thin on screen and are more vertical than horizontal. + * + * @param buffer the rendering context to draw into + * @param alpha the alpha value for the entire line + */ + private void drawSinglePixelVerticalLine(final RenderingContext buffer, + final int alpha, + final Point2D onScreenPoint1, + final Point2D onScreenPoint2) { + int yStart = (int) onScreenPoint1.y; + int yEnd = (int) onScreenPoint2.y; + + int lineWidth; + int xBase; + + if (yStart > yEnd) { + final int tmp = yStart; + yStart = yEnd; + yEnd = tmp; + lineWidth = (int) (onScreenPoint1.x - onScreenPoint2.x); + xBase = (int) onScreenPoint2.x; + } else { + xBase = (int) onScreenPoint1.x; + lineWidth = (int) (onScreenPoint2.x - onScreenPoint1.x); + } + + final int lineHeight = yEnd - yStart; + if (lineHeight == 0) + return; + + final int[] pixels = buffer.pixels; + final int backgroundAlpha = 255 - alpha; + + final int redWithAlpha = color.r * alpha; + final int greenWithAlpha = color.g * alpha; + final int blueWithAlpha = color.b * alpha; + + for (int relativeY = 0; relativeY <= lineHeight; relativeY++) { + final int y = yStart + relativeY; + + if ((y >= buffer.renderMinY) && (y < buffer.renderMaxY)) { + if ((y >= 0) && (y < buffer.height)) { + + final int x = xBase + ((relativeY * lineWidth) / lineHeight); + if ((x >= buffer.renderMinX) && (x < buffer.renderMaxX)) { + int offset = (y * buffer.width) + x; + + final int dest = pixels[offset]; + final int destR = (dest >> 16) & 0xff; + final int destG = (dest >> 8) & 0xff; + final int destB = dest & 0xff; + + final int newR = ((destR * backgroundAlpha) + redWithAlpha) >> 8; + final int newG = ((destG * backgroundAlpha) + greenWithAlpha) >> 8; + final int newB = ((destB * backgroundAlpha) + blueWithAlpha) >> 8; + + pixels[offset] = (newR << 16) | (newG << 8) | newB; + } + } + } + } + } + + /** + * Finds the index of the first interpolator (starting from startPointer) that contains the given Y coordinate. + * + * @param lineInterpolators the interpolators array + * @param startPointer the index to start searching from + * @param y the Y coordinate to search for + * @return the index of the interpolator, or -1 if not found + */ + private int getLineInterpolator(final LineInterpolator[] lineInterpolators, + final int startPointer, final int y) { + + for (int i = startPointer; i < lineInterpolators.length; i++) + if (lineInterpolators[i].containsY(y)) + return i; + return -1; + } + + /** + * The thick-line path widens the line perpendicular to its direction by + * the projected endpoint radii, reaching beyond the vertex Y range. + * The thin paths stay within the vertex Y range, but there the radius + * is below 1 pixel anyway. + */ + @Override + protected double getScreenYMargin(final RenderingContext renderingContext) { + final List clipped = clippedVertices(renderingContext); + final Vertex p1 = clipped != null ? clipped.get(0) : vertices.get(0); + final Vertex p2 = clipped != null ? clipped.get(1) : vertices.get(1); + final double point1radius = (renderingContext.width * LINE_WIDTH_MULTIPLIER * width) + / p1.transformedCoordinate(renderingContext).z; + final double point2radius = (renderingContext.width * LINE_WIDTH_MULTIPLIER * width) + / p2.transformedCoordinate(renderingContext).z; + return Math.max(point1radius, point2radius); + } + + /** + * The thick-line widening is perpendicular to the line direction, so it + * reaches past the vertex range on the X axis exactly as much as on Y. + */ + @Override + protected double getScreenXMargin(final RenderingContext renderingContext) { + return getScreenYMargin(renderingContext); + } + + /** + * Renders this line to the screen using perspective-correct width and alpha blending. + * + *

This method handles two rendering modes:

+ *
    + *
  • Thin lines: When the projected width is below threshold, draws single-pixel + * lines with alpha adjusted for sub-pixel appearance.
  • + *
  • Thick lines: Creates four edge interpolators and fills the rectangular area + * scanline by scanline with perspective-correct alpha fading at edges.
  • + *
+ * + * @param buffer the rendering context containing the pixel buffer + */ + @Override + public void paint(final RenderingContext buffer) { + // Lines don't participate in depth (overlay semantics) — paint + // only in the alpha pass. + if (buffer.depthPass == 1) + return; + + // Near-plane clip output takes precedence: a straddling line is + // shortened to its in-front endpoint plus the intersection point. + final List clipped = clippedVertices(buffer); + final Vertex endpoint1 = clipped != null ? clipped.get(0) : vertices.get(0); + final Vertex endpoint2 = clipped != null ? clipped.get(1) : vertices.get(1); + + final Point2D onScreenPoint1 = endpoint1.onScreenCoordinate(buffer); + final Point2D onScreenPoint2 = endpoint2.onScreenCoordinate(buffer); + + final double xp = onScreenPoint2.x - onScreenPoint1.x; + final double yp = onScreenPoint2.y - onScreenPoint1.y; + + final double point1radius = (buffer.width * LINE_WIDTH_MULTIPLIER * width) + / endpoint1.transformedCoordinate(buffer).z; + final double point2radius = (buffer.width * LINE_WIDTH_MULTIPLIER * width) + / endpoint2.transformedCoordinate(buffer).z; + + if ((point1radius < MINIMUM_WIDTH_THRESHOLD) + || (point2radius < MINIMUM_WIDTH_THRESHOLD)) { + + double averageRadius = (point1radius + point2radius) / 2; + + if (averageRadius > 1) + averageRadius = 1; + + final int alpha = (int) (color.a * averageRadius); + if (alpha < 2) + return; + + if (Math.abs(xp) > Math.abs(yp)) + drawSinglePixelHorizontalLine(buffer, alpha, onScreenPoint1, onScreenPoint2); + else + drawSinglePixelVerticalLine(buffer, alpha, onScreenPoint1, onScreenPoint2); + return; + } + + final double lineLength = Math.sqrt((xp * xp) + (yp * yp)); + + final double yinc1 = (point1radius * xp) / lineLength; + final double yinc2 = (point2radius * xp) / lineLength; + + final double xdec1 = (point1radius * yp) / lineLength; + final double xdec2 = (point2radius * yp) / lineLength; + + final double p1x1 = onScreenPoint1.x - xdec1; + final double p1y1 = onScreenPoint1.y + yinc1; + + final double p1x2 = onScreenPoint1.x + xdec1; + final double p1y2 = onScreenPoint1.y - yinc1; + + final double p2x1 = onScreenPoint2.x - xdec2; + final double p2y1 = onScreenPoint2.y + yinc2; + + final double p2x2 = onScreenPoint2.x + xdec2; + final double p2y2 = onScreenPoint2.y - yinc2; + + // Get thread-local interpolators + final LineInterpolator[] lineInterpolators = LINE_INTERPOLATORS.get(); + + lineInterpolators[0].setPoints(p1x1, p1y1, 1d, p2x1, p2y1, 1d); + lineInterpolators[1].setPoints(p1x2, p1y2, -1d, p2x2, p2y2, -1d); + + lineInterpolators[2].setPoints(p1x1, p1y1, 1d, p1x2, p1y2, -1d); + lineInterpolators[3].setPoints(p2x1, p2y1, 1d, p2x2, p2y2, -1d); + + double ymin = p1y1; + if (p1y2 < ymin) + ymin = p1y2; + if (p2y1 < ymin) + ymin = p2y1; + if (p2y2 < ymin) + ymin = p2y2; + if (ymin < 0) + ymin = 0; + + double ymax = p1y1; + if (p1y2 > ymax) + ymax = p1y2; + if (p2y1 > ymax) + ymax = p2y1; + if (p2y2 > ymax) + ymax = p2y2; + if (ymax >= buffer.height) + ymax = buffer.height - 1; + + // clamp to render Y bounds + ymin = Math.max(ymin, buffer.renderMinY); + ymax = Math.min(ymax, buffer.renderMaxY - 1); + if (ymin > ymax) + return; + + for (int y = (int) ymin; y <= ymax; y++) { + final int li1 = getLineInterpolator(lineInterpolators, 0, y); + if (li1 != -1) { + final int li2 = getLineInterpolator(lineInterpolators, li1 + 1, y); + if (li2 != -1) + drawHorizontalLine(lineInterpolators[li1], lineInterpolators[li2], y, buffer); + } + } + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineAppearance.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineAppearance.java new file mode 100644 index 0000000..e335b2f --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineAppearance.java @@ -0,0 +1,97 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; + + +/** + * Factory for creating Line objects with consistent appearance settings. + *

+ * This class encapsulates common line styling parameters (width and color) to + * avoid redundant configuration. It provides multiple constructors for + * flexibility and ensures default values are used when not specified. + * + *

Example usage:

+ *
{@code
+ * // Create a line appearance with default color and width 2.0
+ * LineAppearance appearance = new LineAppearance(2.0, Color.RED);
+ *
+ * // Create multiple lines with the same appearance
+ * Line line1 = appearance.getLine(new Point3D(0, 0, 100), new Point3D(10, 0, 100));
+ * Line line2 = appearance.getLine(new Point3D(0, 10, 100), new Point3D(10, 10, 100));
+ *
+ * // Override color for a specific line
+ * Line blueLine = appearance.getLine(p1, p2, Color.BLUE);
+ * }
+ */ +public class LineAppearance { + + private final double lineWidth; + + private Color color = new Color(100, 100, 255, 255); + + /** + * Creates a line appearance with default width (1.0) and default color (light blue). + */ + public LineAppearance() { + lineWidth = 1; + } + + /** + * Creates a line appearance with the specified width and default color (light blue). + * + * @param lineWidth the line width in world units + */ + public LineAppearance(final double lineWidth) { + this.lineWidth = lineWidth; + } + + /** + * Creates a line appearance with the specified width and color. + * + * @param lineWidth the line width in world units + * @param color the line color + */ + public LineAppearance(final double lineWidth, final Color color) { + this.lineWidth = lineWidth; + this.color = color; + } + + /** + * Creates a line between two points using this appearance's width and color. + * + * @param point1 the starting point of the line + * @param point2 the ending point of the line + * @return a new Line instance + */ + public Line getLine(final Point3D point1, final Point3D point2) { + return new Line(point1, point2, color, lineWidth); + } + + /** + * Creates a line between two points using this appearance's width and a custom color. + * + * @param point1 the starting point of the line + * @param point2 the ending point of the line + * @param color the color for this specific line (overrides the default) + * @return a new Line instance + */ + public Line getLine(final Point3D point1, final Point3D point2, + final Color color) { + return new Line(point1, point2, color, lineWidth); + } + + /** + * Returns the line width configured for this appearance. + * + * @return the line width in world units + */ + public double getLineWidth() { + return lineWidth; + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineInterpolator.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineInterpolator.java new file mode 100644 index 0000000..83e8c6a --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineInterpolator.java @@ -0,0 +1,101 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line; + +/** + * Interpolates between two points along a line for scanline rendering. + *

+ * This class calculates screen coordinates and depth values (d) for a given Y + * position. It supports perspective-correct interpolation by tracking the + * distance between points and using it to compute step increments. + *

+ * The comparison logic prioritizes interpolators with greater vertical coverage + * to optimize scanline ordering. + */ +public class LineInterpolator { + + private double x1, y1, d1, x2, y2, d2; + + private double d; + private int height; + private int width; + private double dinc; + + /** + * Creates a new line interpolator with uninitialized endpoints. + */ + public LineInterpolator() { + } + + /** + * Checks if the given Y coordinate falls within the vertical span of this line. + * + * @param y the Y coordinate to test + * @return {@code true} if y is between y1 and y2 (inclusive) + */ + public boolean containsY(final int y) { + + if (y1 < y2) { + if (y >= y1) + return y <= y2; + } else if (y >= y2) + return y <= y1; + + return false; + } + + /** + * Returns the depth value (d) at the current Y position. + * + * @return the interpolated depth value + */ + public double getD() { + return d; + } + + /** + * Computes the X coordinate for the given Y position. + * + * @param y the Y coordinate + * @return the interpolated X coordinate + */ + public int getX(final int y) { + if (height == 0) + return (int) (x2 + x1) / 2; + + final int distanceFromY1 = y - (int) y1; + + d = d1 + ((dinc * distanceFromY1) / height); + + return (int) x1 + ((width * distanceFromY1) / height); + } + + /** + * Sets the endpoints and depth values for this line interpolator. + * + * @param x1 the X coordinate of the first point + * @param y1 the Y coordinate of the first point + * @param d1 the depth value at the first point + * @param x2 the X coordinate of the second point + * @param y2 the Y coordinate of the second point + * @param d2 the depth value at the second point + */ + public void setPoints(final double x1, final double y1, final double d1, + final double x2, final double y2, final double d2) { + + this.x1 = x1; + this.y1 = y1; + this.d1 = d1; + + this.x2 = x2; + this.y2 = y2; + this.d2 = d2; + + height = (int) y2 - (int) y1; + width = (int) x2 - (int) x1; + + dinc = d2 - d1; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/package-info.java new file mode 100644 index 0000000..1e3032d --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/package-info.java @@ -0,0 +1,22 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * 3D line segment rendering with perspective-correct width and alpha blending. + * + *

Lines are rendered with width that adjusts based on distance from the viewer. + * The rendering uses interpolators for smooth edges and proper alpha blending.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line} - The line shape
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance} - Color and width configuration
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineInterpolator} - Scanline edge interpolation
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line + */ + +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/package-info.java new file mode 100644 index 0000000..57d2f80 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/package-info.java @@ -0,0 +1,28 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Primitive shape implementations for the rasterization pipeline. + * + *

Basic shapes are the building blocks of 3D scenes. Each can be rendered + * independently and combined to create more complex objects.

+ * + *

Subpackages:

+ *
    + *
  • {@code line} - 3D line segments with perspective-correct width
  • + *
  • {@code solidpolygon} - Solid-color triangles with flat shading
  • + *
  • {@code texturedpolygon} - Triangles with UV-mapped textures
  • + *
+ * + *

Additional basic shapes:

+ *
    + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.Billboard} - Textures that always face the camera
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.GlowingPoint} - Circular gradient billboards
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.Billboard + */ + +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/LineInterpolator.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/LineInterpolator.java new file mode 100644 index 0000000..6fcef60 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/LineInterpolator.java @@ -0,0 +1,151 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; + +import static java.lang.Math.round; + +/** + * Interpolates the x coordinate along a 2D line edge for scanline-based polygon rasterization. + * + *

{@code LineInterpolator} represents one edge of a polygon in screen space, defined by + * two {@link Point2D} endpoints. Given a scanline y coordinate, it computes the corresponding + * x coordinate via linear interpolation. This is a core building block for the solid polygon + * rasterizer, which fills triangles by sweeping horizontal scanlines and using two + * {@code LineInterpolator} instances to find the left and right x boundaries at each y level.

+ * + *

Subpixel precision: This class uses double-precision arithmetic throughout + * the interpolation pipeline to eliminate T-junction gaps. Vertices that should be at the + * same position but land at slightly different screen coordinates (e.g., 100.4 vs 100.6) + * will produce consistent interpolated results when rounded, ensuring adjacent polygons + * fill seamlessly without gaps.

+ * + *

Instances are {@link Comparable}, sorted by absolute height (tallest first) and then + * by width. This ordering is used during rasterization to select the primary (longest) edge + * of the triangle for the outer scanline loop.

+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon + * @see Point2D + */ +public class LineInterpolator { + + /** + * Small epsilon value for comparing near-zero heights to detect horizontal edges. + */ + private static final double EPSILON = 0.0001; + /** + * The first endpoint of this edge. + */ + Point2D p1; + /** + * The second endpoint of this edge. + */ + Point2D p2; + /** + * The vertical span (p2.y - p1.y) in double precision, which may be negative. + * + *

Stored as double to preserve subpixel precision during interpolation, + * eliminating rounding errors that cause T-junction gaps.

+ */ + private double height; + /** + * The horizontal span (p2.x - p1.x) in double precision, which may be negative. + * + *

Stored as double to preserve subpixel precision during interpolation.

+ */ + private double width; + + /** + * 1/z (the depth-buffer quantity) at the endpoints. Linear in screen + * space, so it rides the same edge interpolation as x; set only via + * {@link #setPointsZW} when the polygon participates in the z-buffer. + */ + private double zw1; + private double zw2; + private double zwSpan; + + /** + * Creates a new line interpolator with uninitialized endpoints. + */ + public LineInterpolator() { + } + + /** + * Tests whether the given y coordinate falls within the vertical span of this edge. + * + *

Uses double-precision comparison to handle subpixel vertex positions correctly.

+ * + * @param y the scanline y coordinate to test + * @return {@code true} if {@code y} is between the y coordinates of the two endpoints (inclusive) + */ + public boolean containsY(final int y) { + final double minY = Math.min(p1.y, p2.y); + final double maxY = Math.max(p1.y, p2.y); + return y >= minY && y <= maxY; + } + + /** + * Computes the interpolated x coordinate rounded to the nearest integer. + * + *

For horizontal edges (height near zero), returns the midpoint x value + * to avoid division by zero. This case should only occur when the edge + * spans exactly one scanline.

+ * + * @param y the scanline y coordinate + * @return the interpolated x coordinate rounded to the nearest integer + */ + public int getX(final int y) { + if (Math.abs(height) < EPSILON) { + return (int) round((p1.x + p2.x) / 2); + } + return (int) round(p1.x + (width * (y - p1.y)) / height); + } + + /** + * Sets the two endpoints of this edge and precomputes the width, height, and absolute height. + * + *

This method stores the endpoints directly and computes spans using double-precision + * arithmetic from the Point2D coordinates.

+ * + * @param p1 the first endpoint + * @param p2 the second endpoint + */ + public void setPoints(final Point2D p1, final Point2D p2) { + this.p1 = p1; + this.p2 = p2; + height = p2.y - p1.y; + width = p2.x - p1.x; + } + + /** + * Sets the depth-buffer endpoint values (1/z) for this edge. + * Companion to {@link #setPoints}; call after it. + * + * @param zw1 1/z at {@code p1} + * @param zw2 1/z at {@code p2} + */ + public void setPointsZW(final double zw1, final double zw2) { + this.zw1 = zw1; + this.zw2 = zw2; + zwSpan = zw2 - zw1; + } + + /** + * Computes the interpolated 1/z (depth value) at the given scanline. + * Uses the same interpolation parameter as {@link #getX}, so depth + * and x stay consistent along the edge. + * + * @param y the scanline y coordinate + * @return the interpolated 1/z + */ + public double getZW(final int y) { + if (Math.abs(height) < EPSILON) { + return (zw1 + zw2) / 2d; + } + return zw1 + (zwSpan * (y - p1.y)) / height; + } + +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java new file mode 100644 index 0000000..6dd3b19 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java @@ -0,0 +1,819 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon; + +import eu.svjatoslav.aukio.e3d.geometry.Plane; +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController; +import eu.svjatoslav.aukio.e3d.math.TransformStack; +import eu.svjatoslav.aukio.e3d.math.Vertex; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static eu.svjatoslav.aukio.e3d.geometry.Polygon.pointWithinPolygon; + +/** + * A solid-color convex polygon renderer supporting N vertices (N >= 3). + * + *

This class serves as the unified polygon type for both rendering and CSG operations. + * It renders convex polygons by decomposing them into triangles using fan triangulation, + * and supports CSG operations directly without conversion to intermediate types.

+ * + *

Rendering:

+ *
    + *
  • Fan triangulation for N-vertex polygons (N-2 triangles)
  • + *
  • Scanline rasterization with per-pixel z-buffering and alpha blending
  • + *
  • Backface culling and flat shading support
  • + *
  • Mouse interaction via point-in-polygon testing
  • + *
+ * + *

CSG Support:

+ *
    + *
  • Lazy-computed plane for BSP operations
  • + *
  • {@link #flip()} for inverting polygon orientation
  • + *
  • {@link #deepClone()} for creating independent copies
  • + *
+ * + *

Usage examples:

+ *
{@code
+ * // Create a triangle
+ * SolidPolygon triangle = new SolidPolygon(
+ *     new Point3D(0, 0, 0),
+ *     new Point3D(50, 0, 0),
+ *     new Point3D(25, 50, 0),
+ *     Color.RED
+ * );
+ *
+ * // Create a quad
+ * SolidPolygon quad = SolidPolygon.quad(
+ *     new Point3D(-50, -50, 0),
+ *     new Point3D(50, -50, 0),
+ *     new Point3D(50, 50, 0),
+ *     new Point3D(-50, 50, 0),
+ *     Color.BLUE
+ * );
+ *
+ * // Use with CSG (via AbstractCompositeShape)
+ * SolidPolygonRectangularBox box = new SolidPolygonRectangularBox(...);
+ * box.subtract(sphere);
+ * }
+ * + * @see Plane for BSP plane operations + * @see LineInterpolator for scanline edge interpolation + */ +public class SolidPolygon extends AbstractCoordinateShape { + + /** + * Thread-local storage for line interpolators used during scanline rasterization. + * + *

Contains three interpolators representing the three edges of a triangle. + * ThreadLocal ensures thread safety when multiple threads render triangles + * concurrently, avoiding allocation during rendering by reusing these objects.

+ */ + private static final ThreadLocal INTERPOLATORS = + ThreadLocal.withInitial(() -> new LineInterpolator[]{ + new LineInterpolator(), new LineInterpolator(), new LineInterpolator() + }); + /** + * Thread-local storage for screen coordinates during rendering. + * Each rendering thread gets its own array to avoid race conditions. + */ + private static final ThreadLocal SCREEN_POINTS = new ThreadLocal<>(); + /** + * Reusable color for shading calculations. + * Computed once during transform phase, used during paint phase. + */ + private final Color shadedColor = new Color(); + /** + * Reusable point for polygon center calculation. + */ + private final Point3D cachedCenter = new Point3D(); + /** + * Reusable point for polygon normal calculation. + */ + private final Point3D cachedNormal = new Point3D(); + /** + * Cached plane containing this polygon, used for BSP operations. + * + *

Lazy-computed on first call to {@link #getPlane()}.

+ */ + private Plane plane; + /** + * The fill color of this polygon. + */ + private Color color; + /** + * Whether flat shading is enabled for this polygon. + */ + private boolean shadingEnabled = false; + + /** + * Whether backface culling is enabled for this polygon. + */ + private boolean backfaceCulling = false; + + // ==================== CONSTRUCTORS ==================== + + /** + * Creates a solid polygon with the specified vertices and color. + * + * @param vertices the vertices defining the polygon (must have at least 3) + * @param color the fill color of the polygon + * @throws IllegalArgumentException if vertices is null or has fewer than 3 vertices + */ + public SolidPolygon(final Point3D[] vertices, final Color color) { + super(createVerticesFromPoints(vertices)); + if (vertices == null || vertices.length < 3) { + throw new IllegalArgumentException( + "Polygon must have at least 3 vertices, but got " + + (vertices == null ? "null" : vertices.length)); + } + this.color = color; + } + + /** + * Creates a solid polygon from a list of points and color. + * + * @param points the list of points defining the polygon (must have at least 3) + * @param color the fill color of the polygon + * @throws IllegalArgumentException if points is null or has fewer than 3 points + */ + public SolidPolygon(final List points, final Color color) { + super(createVerticesFromPoints(points)); + if (points == null || points.size() < 3) { + throw new IllegalArgumentException( + "Polygon must have at least 3 vertices, but got " + + (points == null ? "null" : points.size())); + } + this.color = color; + } + + /** + * Private constructor for creating a polygon from existing vertices. + * + *

Parameter order (color first) avoids erasure conflict with + * {@link #SolidPolygon(List, Color)} which takes List<Point3D>.

+ * + * @param color the fill color of the polygon + * @param vertices the list of Vertex objects (used directly, not copied) + */ + private SolidPolygon(final Color color, final List vertices) { + super(vertices); + this.color = color; + } + + /** + * Creates a solid triangle with the specified vertices and color. + * + * @param point1 the first vertex position + * @param point2 the second vertex position + * @param point3 the third vertex position + * @param color the fill color + */ + public SolidPolygon(final Point3D point1, final Point3D point2, + final Point3D point3, final Color color) { + super(new Vertex(point1), new Vertex(point2), new Vertex(point3)); + this.color = color; + } + + /** + * Creates a solid polygon from existing vertices. + * + *

Used for CSG operations and cloning where vertices already exist. + * The vertex list is used directly (not copied), so callers should not + * modify the list after passing it to this method.

+ * + * @param vertices the list of Vertex objects (used directly, not copied) + * @param color the fill color of the polygon + * @return a new SolidPolygon with the given vertices (shading disabled by default) + * @throws IllegalArgumentException if vertices is null or has fewer than 3 vertices + */ + public static SolidPolygon fromVertices(final List vertices, final Color color) { + return fromVertices(vertices, color, false); + } + + /** + * Creates a solid polygon from existing vertices with specified shading. + * + *

Used for CSG operations and cloning where vertices already exist. + * The vertex list is used directly (not copied), so callers should not + * modify the list after passing it to this method.

+ * + * @param vertices the list of Vertex objects (used directly, not copied) + * @param color the fill color of the polygon + * @param shadingEnabled whether shading is enabled for this polygon + * @return a new SolidPolygon with the given vertices and shading setting + * @throws IllegalArgumentException if vertices is null or has fewer than 3 vertices + */ + public static SolidPolygon fromVertices(final List vertices, final Color color, + final boolean shadingEnabled) { + if (vertices == null || vertices.size() < 3) { + throw new IllegalArgumentException( + "Polygon must have at least 3 vertices, but got " + + (vertices == null ? "null" : vertices.size())); + } + final SolidPolygon polygon = new SolidPolygon(color, vertices); + polygon.setShadingEnabled(shadingEnabled); + return polygon; + } + + // ==================== STATIC FACTORY METHODS ==================== + + /** + * Creates a triangle (3-vertex polygon). + * + * @param p1 the first vertex + * @param p2 the second vertex + * @param p3 the third vertex + * @param color the fill color + * @return a new SolidPolygon with 3 vertices + */ + public static SolidPolygon triangle(final Point3D p1, final Point3D p2, + final Point3D p3, final Color color) { + return new SolidPolygon(p1, p2, p3, color); + } + + /** + * Creates a quad (4-vertex polygon). + * + * @param p1 the first vertex + * @param p2 the second vertex + * @param p3 the third vertex + * @param p4 the fourth vertex + * @param color the fill color + * @return a new SolidPolygon with 4 vertices + */ + public static SolidPolygon quad(final Point3D p1, final Point3D p2, + final Point3D p3, final Point3D p4, final Color color) { + return new SolidPolygon(new Point3D[]{p1, p2, p3, p4}, color); + } + + // ==================== VERTEX HELPER METHODS ==================== + + /** + * Helper method to create Vertex list from Point3D array. + */ + private static List createVerticesFromPoints(final Point3D[] points) { + if (points == null || points.length < 3) { + return new ArrayList<>(); + } + final List verts = new ArrayList<>(points.length); + for (final Point3D point : points) { + verts.add(new Vertex(point)); + } + return verts; + } + + /** + * Helper method to create Vertex list from Point3D list. + */ + private static List createVerticesFromPoints(final List points) { + if (points == null || points.size() < 3) { + return new ArrayList<>(); + } + final List verts = new ArrayList<>(points.size()); + for (final Point3D point : points) { + verts.add(new Vertex(point)); + } + return verts; + } + + /** + * Draws a horizontal scanline between two edge interpolators with alpha + * blending and per-pixel depth testing. + * + *

The 1/z endpoint values ride the interpolators' zw channel; every + * pixel is depth-tested before writing. Opaque pixels write depth (pass + * 1), translucent pixels blend without a depth write (pass 2), so + * translucency never occludes.

+ * + * @param line1 the left edge interpolator + * @param line2 the right edge interpolator + * @param y the Y coordinate of the scanline + * @param renderBuffer the rendering context to draw into + * @param color the color to draw with + */ + private static void drawHorizontalLine(final LineInterpolator line1, + final LineInterpolator line2, final int y, + final RenderingContext renderBuffer, final Color color) { + + int x1 = line1.getX(y); + int x2 = line2.getX(y); + + double zw1 = line1.getZW(y); + double zw2 = line2.getZW(y); + + if (x1 > x2) { + final int tmp = x1; + x1 = x2; + x2 = tmp; + final double tmpZw = zw1; + zw1 = zw2; + zw2 = tmpZw; + } + + final double realX1 = x1; + final double realWidth = x2 - x1; + + if (x1 < renderBuffer.renderMinX) x1 = renderBuffer.renderMinX; + + // x2 is exclusive (loop paints [x1, x2)): clamp to renderMaxX, + // not renderMaxX-1, or the rightmost tile column stays unpainted + if (x2 >= renderBuffer.renderMaxX) x2 = renderBuffer.renderMaxX; + + final int width = x2 - x1; + if (width <= 0) + return; + + int offset = (y * renderBuffer.width) + x1; + final int[] pixels = renderBuffer.pixels; + final float[] depth = renderBuffer.depth; + // Alpha pass (depthPass 2): depth-test but never depth-write + final boolean writeDepth = renderBuffer.depthPass != 2; + + final double dzw = (zw2 - zw1) / realWidth; + double zw = zw1 + dzw * (x1 - realX1); + + final int polygonAlpha = color.a; + final int r = color.r; + final int g = color.g; + final int b = color.b; + + if (polygonAlpha == 255) { + final int pixel = (r << 16) | (g << 8) | b; + for (int i = 0; i < width; i++) { + if (zw > depth[offset] - RenderingContext.DEPTH_MARGIN_DZ * zw * zw) { + pixels[offset] = pixel; + if (writeDepth) + depth[offset] = (float) zw; + } + offset++; + zw += dzw; + } + } else { + final int backgroundAlpha = 255 - polygonAlpha; + + final int redWithAlpha = r * polygonAlpha; + final int greenWithAlpha = g * polygonAlpha; + final int blueWithAlpha = b * polygonAlpha; + + for (int i = 0; i < width; i++) { + if (zw > depth[offset] - RenderingContext.DEPTH_MARGIN_DZ * zw * zw) { + final int dest = pixels[offset]; + final int destR = (dest >> 16) & 0xff; + final int destG = (dest >> 8) & 0xff; + final int destB = dest & 0xff; + + final int newR = ((destR * backgroundAlpha) + redWithAlpha) >> 8; + final int newG = ((destG * backgroundAlpha) + greenWithAlpha) >> 8; + final int newB = ((destB * backgroundAlpha) + blueWithAlpha) >> 8; + + pixels[offset] = (newR << 16) | (newG << 8) | newB; + } + offset++; + zw += dzw; + } + } + } + + /** + * Renders a triangle using scanline rasterization. + * + *

This static method handles:

+ *
    + *
  • Rounding vertices to integer screen coordinates
  • + *
  • Mouse hover detection via point-in-triangle test
  • + *
  • Viewport clipping
  • + *
  • Scanline rasterization with per-pixel z-buffering and alpha blending
  • + *
+ * + * @param context the rendering context + * @param onScreenPoint1 the first vertex in screen coordinates + * @param onScreenPoint2 the second vertex in screen coordinates + * @param onScreenPoint3 the third vertex in screen coordinates + * @param z1 camera-space z of the first vertex + * @param z2 camera-space z of the second vertex + * @param z3 camera-space z of the third vertex + * @param mouseInteractionController optional controller for mouse events, or null + * @param color the fill color + */ + public static void drawTriangle(final RenderingContext context, + final Point2D onScreenPoint1, final Point2D onScreenPoint2, + final Point2D onScreenPoint3, + final double z1, final double z2, final double z3, + final MouseInteractionController mouseInteractionController, + final Color color) { + + if (mouseInteractionController != null) + if (context.getMouseEvent() != null) + if (pointWithinPolygon(context.getMouseEvent().coordinate, onScreenPoint1, onScreenPoint2, onScreenPoint3)) + context.setCurrentObjectUnderMouseCursor(mouseInteractionController); + + if (color.isTransparent()) return; + + // Copy coordinates to local variables (don't modify original Point2D) + // Keep double precision to eliminate T-junction gaps from truncation errors + final double y1 = onScreenPoint1.y; + final double y2 = onScreenPoint2.y; + final double y3 = onScreenPoint3.y; + + // Find top-most point (use ceil to include all pixels triangle touches) + int yTop = (int) Math.ceil(Math.min(y1, Math.min(y2, y3))); + if (yTop < 0) yTop = 0; + + // Find bottom-most point (use floor to include all pixels triangle touches) + int yBottom = (int) Math.floor(Math.max(y1, Math.max(y2, y3))); + if (yBottom >= context.height) yBottom = context.height - 1; + + // Clamp to render Y bounds (use renderMaxY - 1 because loop is inclusive) + yTop = Math.max(yTop, context.renderMinY); + yBottom = Math.min(yBottom, context.renderMaxY - 1); + if (yTop > yBottom) return; + + // Paint using line interpolators + final LineInterpolator[] interp = INTERPOLATORS.get(); + final LineInterpolator li1 = interp[0]; + final LineInterpolator li2 = interp[1]; + final LineInterpolator li3 = interp[2]; + li1.setPoints(onScreenPoint1, onScreenPoint2); + li2.setPoints(onScreenPoint1, onScreenPoint3); + li3.setPoints(onScreenPoint2, onScreenPoint3); + + // 1/z rides the same edge interpolation; spans depth-test per pixel + li1.setPointsZW(1d / z1, 1d / z2); + li2.setPointsZW(1d / z1, 1d / z3); + li3.setPointsZW(1d / z2, 1d / z3); + + for (int y = yTop; y <= yBottom; y++) { + if (li1.containsY(y)) { + if (li2.containsY(y)) { + drawHorizontalLine(li1, li2, y, context, color); + } else if (li3.containsY(y)) { + drawHorizontalLine(li1, li3, y, context, color); + } + } else if (li2.containsY(y)) { + if (li3.containsY(y)) { + drawHorizontalLine(li2, li3, y, context, color); + } + } + } + } + + /** + * Returns the number of vertices in this polygon. + * + * @return the vertex count + */ + public int getVertexCount() { + return vertices.size(); + } + + /** + * Returns the fill color of this polygon. + * + * @return the polygon color + */ + public Color getColor() { + return color; + } + + /** + * Sets the fill color of this polygon. + * + * @param color the new color + */ + public void setColor(final Color color) { + this.color = color; + } + + /** + * Checks if shading is enabled for this polygon. + * + * @return true if shading is enabled, false otherwise + */ + public boolean isShadingEnabled() { + return shadingEnabled; + } + + /** + * Enables or disables shading for this polygon. + * + * @param shadingEnabled true to enable shading, false to disable + */ + public void setShadingEnabled(final boolean shadingEnabled) { + this.shadingEnabled = shadingEnabled; + } + + // ==================== CSG SUPPORT ==================== + + /** + * Checks if backface culling is enabled for this polygon. + * + * @return {@code true} if backface culling is enabled + */ + public boolean isBackfaceCullingEnabled() { + return backfaceCulling; + } + + /** + * Enables or disables backface culling for this polygon. + * + * @param backfaceCulling {@code true} to enable backface culling + */ + public void setBackfaceCulling(final boolean backfaceCulling) { + this.backfaceCulling = backfaceCulling; + } + + /** + * Returns the plane containing this polygon. + * + *

Computed from the first three vertices and cached for reuse. + * Used by BSP tree construction for spatial partitioning.

+ * + * @return the Plane containing this polygon + */ + public Plane getPlane() { + if (plane == null) { + plane = Plane.fromPoints( + vertices.get(0).coordinate, + vertices.get(1).coordinate, + vertices.get(2).coordinate + ); + } + return plane; + } + + // ==================== RENDERING ==================== + + /** + * Flips the orientation of this polygon. + * + *

Reverses the vertex order and negates vertex normals. + * Also flips the cached plane if computed. Used during CSG operations + * when inverting solids.

+ */ + public void flip() { + Collections.reverse(vertices); + for (final Vertex vertex : vertices) vertex.flip(); + if (plane != null) plane.flip(); + } + + /** + * Creates a deep clone of this polygon. + * + *

Clones all vertices and preserves the color, shading, and backface culling settings. + * Used by CSG operations to create independent copies before modification.

+ * + * @return a new SolidPolygon with cloned data and preserved settings + */ + public SolidPolygon deepClone() { + final List clonedVertices = new ArrayList<>(vertices.size()); + for (final Vertex v : vertices) { + clonedVertices.add(v.clone()); + } + final SolidPolygon clone = SolidPolygon.fromVertices(clonedVertices, color, shadingEnabled); + clone.backfaceCulling = this.backfaceCulling; + return clone; + } + + /** + * Calculates the centroid (geometric center) of this polygon. + * + * @param result the point to store the center in + */ + private void calculateCenter(final Point3D result) { + if (vertices.isEmpty()) { + result.x = result.y = result.z = 0; + return; + } + + double sumX = 0, sumY = 0, sumZ = 0; + for (final Vertex v : vertices) { + sumX += v.coordinate.x; + sumY += v.coordinate.y; + sumZ += v.coordinate.z; + } + + result.x = sumX / vertices.size(); + result.y = sumY / vertices.size(); + result.z = sumZ / vertices.size(); + } + + /** + * Calculates the signed area of this polygon in screen space. + * + * @param screenPoints the screen coordinates of this polygon's vertices + * @param vertexCount the number of vertices in the polygon + * @return the signed area (negative = front-facing in Y-down coordinate system) + */ + private double calculateSignedArea(final Point2D[] screenPoints, final int vertexCount) { + double area = 0; + final int n = vertexCount; + for (int i = 0; i < n; i++) { + final Point2D curr = screenPoints[i]; + final Point2D next = screenPoints[(i + 1) % n]; + area += curr.x * next.y - next.x * curr.y; + } + return area / 2.0; + } + + /** + * Tests whether a point lies inside this polygon using ray-casting. + * + * @param point the point to test + * @param screenPoints the screen coordinates of this polygon's vertices + * @param vertexCount the number of vertices in the polygon + * @return {@code true} if the point is inside the polygon + */ + private boolean isPointInsidePolygon(final Point2D point, final Point2D[] screenPoints, + final int vertexCount) { + int intersectionCount = 0; + final int n = vertexCount; + + for (int i = 0; i < n; i++) { + final Point2D p1 = screenPoints[i]; + final Point2D p2 = screenPoints[(i + 1) % n]; + + if (intersectsRay(point, p1, p2)) { + intersectionCount++; + } + } + + return (intersectionCount % 2) == 1; + } + + /** + * Tests if a horizontal ray from the point intersects the edge. + */ + private boolean intersectsRay(final Point2D point, Point2D edgeP1, Point2D edgeP2) { + if (edgeP1.y > edgeP2.y) { + final Point2D tmp = edgeP1; + edgeP1 = edgeP2; + edgeP2 = tmp; + } + + if (point.y < edgeP1.y || point.y > edgeP2.y) { + return false; + } + + final double dy = edgeP2.y - edgeP1.y; + if (Math.abs(dy) < 0.0001) { + return false; + } + + final double t = (point.y - edgeP1.y) / dy; + final double intersectX = edgeP1.x + t * (edgeP2.x - edgeP1.x); + + return point.x >= intersectX; + } + + /** + * Renders this polygon to the screen. + * + * @param renderBuffer the rendering context containing the pixel buffer + */ + @Override + public void paint(final RenderingContext renderBuffer) { + // Near-plane clip output takes precedence: a straddling triangle + // becomes a triangle or quad of in-front vertices; the original + // vertices hold behind-camera positions with garbage projections. + final List clipped = clippedVertices(renderBuffer); + final List active = clipped != null ? clipped : vertices; + + if (active.size() < 3 || color.isTransparent()) { + return; + } + + // Use pre-computed shaded color (computed during transform phase) + final Color paintColor = shadingEnabled ? shadedColor : color; + + // Z-buffer two-pass classification: opaque polygons paint in + // pass 1 (depth test + write), translucent ones in pass 2 + // (depth test, no write — translucency must not occlude). + // See RenderAggregator.paintSorted. + final boolean alphaClass = paintColor.a != 255; + if ((renderBuffer.depthPass == 1) == alphaClass) + return; + + // Get thread-local screen points array + final Point2D[] screenPoints = getScreenPoints(active.size()); + final double[] cameraZ = getCameraZ(active.size()); + + // Get screen coordinates and per-vertex depth + for (int i = 0; i < active.size(); i++) { + final Vertex vertex = active.get(i); + screenPoints[i] = vertex.onScreenCoordinate(renderBuffer); + cameraZ[i] = vertex.transformedCoordinate(renderBuffer).z; + } + + // Backface culling check + if (backfaceCulling) { + final double signedArea = calculateSignedArea(screenPoints, active.size()); + if (signedArea >= 0) { + return; + } + } + + // Mouse interaction + if (mouseInteractionController != null && renderBuffer.getMouseEvent() != null) { + if (isPointInsidePolygon(renderBuffer.getMouseEvent().coordinate, screenPoints, active.size())) { + renderBuffer.setCurrentObjectUnderMouseCursor(mouseInteractionController); + } + } + + // Only triangles can be rendered directly; N-vertex polygons must be triangulated + // by AbstractCompositeShape.rebuildRenderList() before rendering. The single + // exception: near-plane clipping can turn a renderable triangle into a quad + // (one corner cut off), painted here as a 2-triangle fan — the clip of a + // convex polygon stays convex, so fan triangulation is exact. + if (clipped == null && active.size() != 3) { + throw new IllegalStateException( + "SolidPolygon with " + active.size() + " vertices cannot be rendered directly. " + + "Only triangles (3 vertices) support direct rendering. " + + "For N-vertex polygons, use AbstractCompositeShape which triangulates when building its render list."); + } + + for (int i = 1; i + 1 < active.size(); i++) { + drawTriangle(renderBuffer, screenPoints[0], screenPoints[i], screenPoints[i + 1], + cameraZ[0], cameraZ[i], cameraZ[i + 1], + mouseInteractionController, paintColor); + } + } + + /** + * Thread-local storage for per-vertex camera-space z during rendering. + */ + private static final ThreadLocal CAMERA_Z = new ThreadLocal<>(); + + /** + * Gets a thread-local camera-z array sized for the given number of vertices. + * + * @param size the required array size + * @return a thread-local double array + */ + private double[] getCameraZ(final int size) { + double[] cameraZ = CAMERA_Z.get(); + if (cameraZ == null || cameraZ.length < size) { + cameraZ = new double[size]; + CAMERA_Z.set(cameraZ); + } + return cameraZ; + } + + /** + * Gets a thread-local screen points array sized for the given number of vertices. + * + * @param size the required array size + * @return a thread-local Point2D array + */ + private Point2D[] getScreenPoints(final int size) { + Point2D[] screenPoints = SCREEN_POINTS.get(); + if (screenPoints == null || screenPoints.length < size) { + screenPoints = new Point2D[size]; + SCREEN_POINTS.set(screenPoints); + } + return screenPoints; + } + + /** + * Transforms vertices to screen space and computes lighting once per frame. + * + *

Overrides parent to add lighting computation during the single-threaded + * transform phase. This ensures lighting is calculated only once per polygon + * per frame, rather than once per render thread.

+ * + * @param transforms the transform stack to apply + * @param aggregator the render aggregator to queue shapes into + * @param renderingContext the rendering context + */ + @Override + public void transform(final TransformStack transforms, + final RenderAggregator aggregator, + final RenderingContext renderingContext) { + // Transform vertices to screen space + super.transform(transforms, aggregator, renderingContext); + + // Compute lighting once during transform phase (single-threaded) + if (shadingEnabled && renderingContext.lightingManager != null) { + calculateCenter(cachedCenter); + // Compute normal from first 3 vertices + Plane.computeNormal( + vertices.get(0).coordinate, + vertices.get(1).coordinate, + vertices.get(2).coordinate, + cachedNormal + ); + renderingContext.lightingManager.computeLighting( + this, cachedCenter, cachedNormal, color, shadedColor); + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/package-info.java new file mode 100644 index 0000000..80d976e --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/package-info.java @@ -0,0 +1,22 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Solid-color polygon rendering with scanline rasterization. + * + *

SolidPolygon is the unified polygon type for both rendering and CSG operations. + * It supports N vertices (N >= 3) and handles perspective-correct interpolation, + * alpha blending, viewport clipping, backface culling, and optional flat shading.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon} - Unified polygon for rendering and CSG
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.LineInterpolator} - Edge interpolation for scanlines
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon + */ + +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/MeshTriangle.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/MeshTriangle.java new file mode 100644 index 0000000..d62fa3b --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/MeshTriangle.java @@ -0,0 +1,124 @@ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator; +import eu.svjatoslav.aukio.e3d.math.TransformStack; + +/** + * Paint/sort handle for one triangle of a {@link TriangleMeshBlock}. The + * triangle's geometry lives in the block's flat arrays (SoA layout); this + * object exists only so the existing sort/bin/paint pipeline — typed on + * individual shapes — can address one triangle. Handles are allocated once + * at block build time and reused every frame; {@link #transform} is a + * no-op because the block transforms all its triangles in one tight loop. + * + *

Per-slot screen data is read from the block's arrays through the + * overridden accessors, so the Z-comparator and tile binning see exactly + * the values an object-backed {@link TexturedTriangle} would expose.

+ */ +final class MeshTriangle extends TexturedTriangle { + + /** + * Scratch Point2D carriers for the flat paint call. Interpolators + * hold references to the screen/UV points they are given, so the + * objects must stay stable for the duration of one paint — but a + * paint never nests, so three screen + three UV points per thread + * suffice and nothing is allocated per triangle. + */ + private static final ThreadLocal SCREEN_SCRATCH = + ThreadLocal.withInitial(() -> new Point2D[]{ + new Point2D(), new Point2D(), new Point2D()}); + private static final ThreadLocal UV_SCRATCH = + ThreadLocal.withInitial(() -> new Point2D[]{ + new Point2D(), new Point2D(), new Point2D()}); + + private final TriangleMeshBlock block; + private final int index; + + MeshTriangle(final TriangleMeshBlock block, final int index, + final eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture texture) { + super(texture); + this.block = block; + this.index = index; + } + + /** + * No-op: the owning block transforms all its triangles (including this + * one) in a single flat-array loop. Queued handles are never + * transformed through the shape path. Sort Z and screen bounds live in + * the inherited per-slot fields, written by the block via + * {@code setSlotScreenState}. + */ + @Override + public void transform(final TransformStack transforms, + final RenderAggregator aggregator, + final RenderingContext renderingContext) { + // intentionally empty — see TriangleMeshBlock.transform + } + + /** + * Publishes this triangle's per-slot screen state (called by the + * owning block during its flat transform loop). Trampoline: the + * inherited setter is protected, so the call must go through the + * subclass. + */ + void publishSlotState(final int slot, final double z, + final double minY, final double maxY, + final double minX, final double maxX) { + setSlotScreenState(slot, z, minY, maxY, minX, maxX); + } + + @Override + public void paint(final RenderingContext renderBuffer) { + final int slot = renderBuffer.vertexSlot; + final Point2D[] screen = SCREEN_SCRATCH.get(); + final Point2D[] uvs = UV_SCRATCH.get(); + + final int clip = block.clipOffset(slot, index); + if (clip >= 0) { + // Near-plane straddler: the block clipped to a loop of 3-4 + // vertices, stored as (x, y, z, u, v, sx, sy) tuples. Paint + // as a fan, mirroring TexturedTriangle.paint's clipped path. + final int count = block.clipCount(slot, index); + final double[] store = block.clipStore(slot); + loadClipVertex(screen[0], uvs[0], store, clip); + for (int i = 1; i + 1 < count; i++) { + loadClipVertex(screen[1], uvs[1], store, clip + i * 7); + loadClipVertex(screen[2], uvs[2], store, clip + (i + 1) * 7); + paintFlat(renderBuffer, block.texture(index), block.backfaceCull(), + screen[0], screen[1], screen[2], + uvs[0], uvs[1], uvs[2], + store[clip + 2], + store[clip + i * 7 + 2], + store[clip + (i + 1) * 7 + 2], + block.clipTtd(slot, clip)); + } + return; + } + + block.loadScreenVertex(screen[0], uvs[0], slot, index, 0, renderBuffer); + block.loadScreenVertex(screen[1], uvs[1], slot, index, 1, renderBuffer); + block.loadScreenVertex(screen[2], uvs[2], slot, index, 2, renderBuffer); + paintFlat(renderBuffer, block.texture(index), block.backfaceCull(), + screen[0], screen[1], screen[2], + uvs[0], uvs[1], uvs[2], + block.camZ(slot, index, 0), + block.camZ(slot, index, 1), + block.camZ(slot, index, 2), + block.origTtd(index)); + } + + /** + * Fills the scratch screen/UV points from one clip-loop entry; screen + * coordinates were stored at clip time with the exact + * {@code Vertex.setCameraSpaceCoordinate} expression. + */ + private void loadClipVertex(final Point2D screen, final Point2D uv, + final double[] store, final int entry) { + screen.x = store[entry + 5]; + screen.y = store[entry + 6]; + uv.x = store[entry + 3]; + uv.y = store[entry + 4]; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PerspectiveBorderInterpolator.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PerspectiveBorderInterpolator.java new file mode 100644 index 0000000..79d39cb --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PerspectiveBorderInterpolator.java @@ -0,0 +1,164 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; + +import static java.lang.Math.round; + +/** + * Border interpolator carrying perspective-corrected texture gradients. + * + *

Quake-style perspective texturing: instead of interpolating texture + * coordinates (u, v) linearly in screen space (affine — wrong except for + * face-on triangles), interpolates (u/z, v/z, 1/z) which ARE linear in + * screen space. The scanline renderer recovers exact (u, v) with one + * reciprocal every N pixels and steps affinely in between, so the divide + * cost is amortized.

+ * + *

The u/v values handed to this interpolator must already be premultiplied + * by the mipmap's multiplication factor and divided by the vertex's + * camera-space z; w values are 1/z.

+ * + * @see PolygonBorderInterpolator + * @see TexturedTriangle + */ +public class PerspectiveBorderInterpolator { + + /** + * Small epsilon value for comparing near-zero heights to detect horizontal edges. + */ + private static final double EPSILON = 0.0001; + + /** The first endpoint of this edge in screen space. */ + private Point2D p1; + /** The second endpoint of this edge in screen space. */ + private Point2D p2; + + /** The vertical span (p2.y - p1.y) in double precision, may be negative. */ + private double height; + /** The horizontal span (p2.x - p1.x) in double precision, may be negative. */ + private double width; + + /** u/z at the endpoints (mipmap factor premultiplied). */ + private double su1, su2; + /** v/z at the endpoints (mipmap factor premultiplied). */ + private double sv1, sv2; + /** 1/z at the endpoints. */ + private double sw1, sw2; + + /** Spans along the edge. */ + private double suSpan, svSpan, swSpan; + + /** + * Biased 1/z (the depth-buffer quantity) at the endpoints. Linear in + * screen space exactly like sw, so it rides the same edge + * interpolation; set only in z-buffer mode via {@link #setPointsZW}. + */ + private double zw1, zw2; + private double zwSpan; + + /** The current Y coordinate being interpolated. */ + private int currentY; + + /** + * Sets the screen endpoints and perspective-corrected gradients for this edge. + * + * @param screenPoint1 the first screen-space endpoint + * @param screenPoint2 the second screen-space endpoint + * @param su1 u/z at the first endpoint (mipmap factor premultiplied) + * @param sv1 v/z at the first endpoint (mipmap factor premultiplied) + * @param sw1 1/z at the first endpoint + * @param su2 u/z at the second endpoint + * @param sv2 v/z at the second endpoint + * @param sw2 1/z at the second endpoint + */ + public void setPoints(final Point2D screenPoint1, final Point2D screenPoint2, + final double su1, final double sv1, final double sw1, + final double su2, final double sv2, final double sw2) { + this.p1 = screenPoint1; + this.p2 = screenPoint2; + this.su1 = su1; + this.sv1 = sv1; + this.sw1 = sw1; + this.su2 = su2; + this.sv2 = sv2; + this.sw2 = sw2; + + height = p2.y - p1.y; + width = p2.x - p1.x; + + suSpan = su2 - su1; + svSpan = sv2 - sv1; + swSpan = sw2 - sw1; + } + + /** + * Tests whether the given y coordinate falls within the vertical span of this edge. + */ + public boolean containsY(final int y) { + final double minY = Math.min(p1.y, p2.y); + final double maxY = Math.max(p1.y, p2.y); + return y >= minY && y <= maxY; + } + + private double interpolationT() { + return (currentY - p1.y) / height; + } + + /** Returns interpolated u/z at the current Y. */ + public double getSU() { + if (Math.abs(height) < EPSILON) + return (su1 + su2) / 2d; + return su1 + interpolationT() * suSpan; + } + + /** Returns interpolated v/z at the current Y. */ + public double getSV() { + if (Math.abs(height) < EPSILON) + return (sv1 + sv2) / 2d; + return sv1 + interpolationT() * svSpan; + } + + /** Returns interpolated 1/z at the current Y. */ + public double getSW() { + if (Math.abs(height) < EPSILON) + return (sw1 + sw2) / 2d; + return sw1 + interpolationT() * swSpan; + } + + /** + * Sets the depth-buffer endpoint values (biased 1/z) for this edge. + * Companion to {@link #setPoints}: kept separate so the classic + * painter path never pays for the extra channel. + */ + public void setPointsZW(final double zw1, final double zw2) { + this.zw1 = zw1; + this.zw2 = zw2; + zwSpan = zw2 - zw1; + } + + /** Returns interpolated biased 1/z (depth value) at the current Y. */ + public double getZW() { + if (Math.abs(height) < EPSILON) + return (zw1 + zw2) / 2d; + return zw1 + interpolationT() * zwSpan; + } + + /** + * Computes the interpolated x coordinate rounded to the nearest integer. + * Identical semantics to {@link PolygonBorderInterpolator#getX()}. + */ + public int getX() { + if (Math.abs(height) < EPSILON) + return (int) round((p1.x + p2.x) / 2); + return (int) round(p1.x + (width * (currentY - p1.y)) / height); + } + + /** Sets the current Y coordinate for interpolation. */ + public void setCurrentY(final int y) { + this.currentY = y; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PolygonBorderInterpolator.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PolygonBorderInterpolator.java new file mode 100644 index 0000000..d3f0893 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PolygonBorderInterpolator.java @@ -0,0 +1,196 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; + +import static java.lang.Math.round; + +/** + * Interpolator for textured polygon edges with perspective correction. + * + *

Maps screen coordinates to texture coordinates while maintaining + * perspective accuracy. Uses double-precision arithmetic to eliminate + * T-junction gaps from truncation errors, matching {@code LineInterpolator} + * behavior in the solid polygon renderer.

+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.LineInterpolator + */ +public class PolygonBorderInterpolator { + + /** + * Small epsilon value for comparing near-zero heights to detect horizontal edges. + */ + private static final double EPSILON = 0.0001; + + /** + * The first endpoint of this edge in screen space. + */ + Point2D p1; + /** + * The second endpoint of this edge in screen space. + */ + Point2D p2; + + /** + * The vertical span (p2.y - p1.y) in double precision, which may be negative. + * + *

Stored as double to preserve subpixel precision during interpolation, + * eliminating rounding errors that cause T-junction gaps.

+ */ + private double height; + /** + * The horizontal span (p2.x - p1.x) in double precision, which may be negative. + */ + private double width; + + /** + * The texture coordinate at the first endpoint. + */ + private Point2D texturePoint1; + /** + * The texture coordinate at the second endpoint. + */ + private Point2D texturePoint2; + /** + * The texture U span (texturePoint2.x - texturePoint1.x). + */ + private double textureWidth; + /** + * The texture V span (texturePoint2.y - texturePoint1.y). + */ + private double textureHeight; + + /** + * The current Y coordinate being interpolated, used for computing texture coordinates. + */ + private int currentY; + + /** + * Creates a new polygon border interpolator. + */ + public PolygonBorderInterpolator() { + } + + /** + * Tests whether the given y coordinate falls within the vertical span of this edge. + * + *

Uses double-precision comparison to handle subpixel vertex positions correctly.

+ * + * @param y the scanline y coordinate to test + * @return {@code true} if {@code y} is between the y coordinates of the two endpoints (inclusive) + */ + public boolean containsY(final int y) { + final double minY = Math.min(p1.y, p2.y); + final double maxY = Math.max(p1.y, p2.y); + return y >= minY && y <= maxY; + } + + /** + * Returns the interpolated texture X coordinate at the current Y position. + * + *

For horizontal edges (height near zero), returns the midpoint texture X.

+ * + * @return the texture X coordinate + */ + public double getTX() { + if (Math.abs(height) < EPSILON) { + return (texturePoint1.x + texturePoint2.x) / 2d; + } + final double t = (currentY - p1.y) / height; + return texturePoint1.x + t * textureWidth; + } + + /** + * Returns the interpolated texture Y coordinate at the current Y position. + * + *

For horizontal edges (height near zero), returns the midpoint texture Y.

+ * + * @return the texture Y coordinate + */ + public double getTY() { + if (Math.abs(height) < EPSILON) { + return (texturePoint1.y + texturePoint2.y) / 2d; + } + final double t = (currentY - p1.y) / height; + return texturePoint1.y + t * textureHeight; + } + + /** + * Computes the interpolated x coordinate rounded to the nearest integer. + * + *

For horizontal edges (height near zero), returns the midpoint x value + * to avoid division by zero.

+ * + * @return the interpolated x coordinate rounded to the nearest integer + */ + public int getX() { + if (Math.abs(height) < EPSILON) { + return (int) round((p1.x + p2.x) / 2); + } + return (int) round(p1.x + (width * (currentY - p1.y)) / height); + } + + /** + * Sets the current Y coordinate for interpolation. + * + * @param y the current Y coordinate + */ + public void setCurrentY(final int y) { + this.currentY = y; + } + + /** + * Sets the screen and texture coordinates for this edge. + * + *

Screen coordinates are stored directly as references. Callers should + * ensure coordinates are not modified during rendering for thread safety.

+ * + * @param screenPoint1 the first screen-space endpoint + * @param screenPoint2 the second screen-space endpoint + * @param texturePoint1 the texture coordinate for the first endpoint + * @param texturePoint2 the texture coordinate for the second endpoint + */ + public void setPoints(final Point2D screenPoint1, final Point2D screenPoint2, + final Point2D texturePoint1, final Point2D texturePoint2) { + + this.p1 = screenPoint1; + this.p2 = screenPoint2; + this.texturePoint1 = texturePoint1; + this.texturePoint2 = texturePoint2; + + height = p2.y - p1.y; + width = p2.x - p1.x; + + textureWidth = texturePoint2.x - texturePoint1.x; + textureHeight = texturePoint2.y - texturePoint1.y; + } + + /** + * Biased 1/z (the depth-buffer quantity) at the endpoints; set only + * in z-buffer mode via {@link #setPointsZW}. Interpolated linearly + * along the edge like the texture coordinates. + */ + private double zw1, zw2, zwSpan; + + /** + * Sets the depth-buffer endpoint values (biased 1/z) for this edge. + * Companion to {@link #setPoints}. + */ + public void setPointsZW(final double zw1, final double zw2) { + this.zw1 = zw1; + this.zw2 = zw2; + zwSpan = zw2 - zw1; + } + + /** Returns interpolated biased 1/z (depth value) at the current Y. */ + public double getZW() { + if (Math.abs(height) < EPSILON) + return (zw1 + zw2) / 2d; + final double t = (currentY - p1.y) / height; + return zw1 + t * zwSpan; + } + +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java new file mode 100644 index 0000000..fb57245 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java @@ -0,0 +1,1475 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.math.Vertex; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.TextureBitmap; + +import java.awt.*; +import java.util.List; + +import static eu.svjatoslav.aukio.e3d.geometry.Polygon.pointWithinPolygon; + +/** + * A textured triangle renderer with perspective-correct texture mapping. + * + *

Quake-style subdivided perspective correction: (u/z, v/z, 1/z) are + * interpolated linearly in screen space and the exact texture coordinate + * is recovered with one reciprocal per subdivision interval, with affine + * stepping in between. This makes screen-size tessellation unnecessary — + * triangles of any size render with correct perspective.

+ * + * @see Texture + * @see Vertex#textureCoordinate + */ +public class TexturedTriangle extends AbstractCoordinateShape { + + private static final ThreadLocal INTERPOLATORS = + ThreadLocal.withInitial(() -> new PolygonBorderInterpolator[]{ + new PolygonBorderInterpolator(), new PolygonBorderInterpolator(), new PolygonBorderInterpolator() + }); + + private static final ThreadLocal PERSPECTIVE_INTERPOLATORS = + ThreadLocal.withInitial(() -> new PerspectiveBorderInterpolator[]{ + new PerspectiveBorderInterpolator(), new PerspectiveBorderInterpolator(), + new PerspectiveBorderInterpolator() + }); + + /** + * Quake-style perspective correction interval: the exact texture + * coordinate (one reciprocal) is computed every this many pixels; + * between correction points the scanline steps affinely. + */ + private static final int PERSPECTIVE_CORRECTION_INTERVAL = 16; + + /** + * Minimum camera-space z for the perspective path. Triangles with any + * vertex closer than this fall back to the affine path (they straddle + * the near plane, where 1/z interpolation is invalid). + */ + private static final double PERSPECTIVE_MIN_Z = 0.001; + + /** A/B tuning knobs for the SDF path, see paintSdf. */ + private static final double SDF_GAMMA = + Double.parseDouble(System.getProperty("e3d.sdf.gamma", "0")); + private static final double SDF_SHARPEN = + Double.parseDouble(System.getProperty("e3d.sdf.sharpen", "2")); + /** Debug: print SDF path decisions when they change. */ + private static final boolean SDF_DEBUG = Boolean.getBoolean("e3d.sdf.debug"); + private static boolean sdfDebugLastPerspective; + private static double sdfDebugLastFootY = -1; + + /** + * When true (default), textured triangles render with Quake-style + * perspective-correct texture mapping. + * Volatile: consulted from parallel paint workers. + */ + private static volatile boolean perspectiveCorrectionEnabled = true; + + /** + * Enables or disables perspective-correct texture mapping. + * When disabled, rendering falls back to plain affine mapping, which + * visibly warps textures on large on-screen triangles at steep angles. + * + * @param enabled {@code true} for perspective-correct mapping + */ + public static void setPerspectiveCorrectionEnabled(final boolean enabled) { + perspectiveCorrectionEnabled = enabled; + } + + /** + * Returns whether perspective-correct texture mapping is enabled. + * + * @return {@code true} when perspective correction is active + */ + public static boolean isPerspectiveCorrectionEnabled() { + return perspectiveCorrectionEnabled; + } + + /** + * The texture to apply to this triangle. + * Volatile: the global illumination system swaps the premultiplied + * lightmap composite on GI threads while paint workers read it. + * Read once per paint call so a triangle never shows a half-updated + * texture. + */ + private volatile Texture texture; + + /** + * Returns the current texture. + * + * @return the texture + */ + public Texture getTexture() { + return texture; + } + + /** + * Atomically swaps the texture. Painters pick up the new texture at the + * next paint call; a triangle in flight finishes with the old one. + * + * @param texture the new texture + */ + public void setTexture(final Texture texture) { + this.texture = texture; + } + + private boolean backfaceCulling = Boolean.getBoolean("e3d.backface"); + + // --- ad-hoc frame profiling (-De3d.prof=true; dead code when off) + /** Master switch, constant-folded when false. */ + private static final boolean PROF = + Boolean.getBoolean("e3d.prof"); + /** paintTriangle invocations. */ + public static final java.util.concurrent.atomic.AtomicLong + PROF_TRIS = new java.util.concurrent.atomic.AtomicLong(); + /** Triangles facing away (engine winding convention). */ + public static final java.util.concurrent.atomic.AtomicLong + PROF_BACKFACE = new java.util.concurrent.atomic.AtomicLong(); + /** Triangles discarded by vertical render-bounds clamp. */ + public static final java.util.concurrent.atomic.AtomicLong + PROF_OFFY = new java.util.concurrent.atomic.AtomicLong(); + /** Triangles with screen bounding box below 2x2 pixels. */ + public static final java.util.concurrent.atomic.AtomicLong + PROF_TINY = new java.util.concurrent.atomic.AtomicLong(); + /** Scanline spans drawn. */ + public static final java.util.concurrent.atomic.AtomicLong + PROF_SPANS = new java.util.concurrent.atomic.AtomicLong(); + /** Pixel loop iterations across all spans. */ + public static final java.util.concurrent.atomic.AtomicLong + PROF_PIXELS = new java.util.concurrent.atomic.AtomicLong(); + + /** Resets all profiling counters. */ + public static void profReset() { + PROF_TRIS.set(0); + PROF_BACKFACE.set(0); + PROF_OFFY.set(0); + PROF_TINY.set(0); + PROF_SPANS.set(0); + PROF_PIXELS.set(0); + } + + /** + * Total UV distance between all texture coordinate pairs. + * Computed at construction time to determine appropriate mipmap level. + */ + private double totalTextureDistance; + + /** + * Creates a textured triangle with the specified vertices and texture. + * + * @param p1 the first vertex (must have textureCoordinate set) + * @param p2 the second vertex (must have textureCoordinate set) + * @param p3 the third vertex (must have textureCoordinate set) + * @param texture the texture to apply + */ + public TexturedTriangle(Vertex p1, Vertex p2, Vertex p3, final Texture texture) { + + super(p1, p2, p3); + this.texture = texture; + computeTotalTextureDistance(); + } + + /** + * Constructor for flat-array-backed subclasses ({@code MeshTriangle}) + * that carry no {@link Vertex} objects and paint exclusively through + * {@link #paintFlat}, which computes the mipmap metric inline. + * {@code totalTextureDistance} is set to a neutral 1 — never read on + * that path. + * + * @param texture the texture the subclass paints with + */ + protected TexturedTriangle(final Texture texture) { + super(0); + this.texture = texture; + this.totalTextureDistance = 1; + } + + /** + * Computes the total UV distance between all texture coordinate pairs. + * Used to determine appropriate mipmap level. + */ + private void computeTotalTextureDistance() { + totalTextureDistance = vertices.get(0).textureCoordinate.getDistanceTo(vertices.get(1).textureCoordinate); + totalTextureDistance += vertices.get(0).textureCoordinate.getDistanceTo(vertices.get(2).textureCoordinate); + totalTextureDistance += vertices.get(1).textureCoordinate.getDistanceTo(vertices.get(2).textureCoordinate); + } + + /** + * Recomputes the mipmap-selection metric after texture coordinates are + * replaced post-construction (used by generated-UV subclasses like + * lightmapped triangles). + */ + protected final void refreshTextureDistance() { + computeTotalTextureDistance(); + } + + /** + * Z-buffer variant of {@link #drawHorizontalLinePerspective}: the + * biased 1/z endpoint values ride the interpolators' zw channel, and + * every pixel is depth-tested BEFORE the texture fetch — rejected + * pixels cost one float compare instead of a texel read. Opaque + * texels (alpha 255) write depth; blended texels write color only, + * so translucency never occludes. Requires + * {@code renderBuffer.depth != null} and {@code setPointsZW} called + * on both interpolators. + */ + private void drawHorizontalLinePerspectiveZ( + final PerspectiveBorderInterpolator line1, + final PerspectiveBorderInterpolator line2, + final int y, + final RenderingContext renderBuffer, + final TextureBitmap textureBitmap) { + + line1.setCurrentY(y); + line2.setCurrentY(y); + + int x1 = line1.getX(); + int x2 = line2.getX(); + + final double su1, sv1, sw1, zw1; + final double su2, sv2, sw2, zw2; + + if (x1 <= x2) { + su1 = line1.getSU(); + sv1 = line1.getSV(); + sw1 = line1.getSW(); + zw1 = line1.getZW(); + su2 = line2.getSU(); + sv2 = line2.getSV(); + sw2 = line2.getSW(); + zw2 = line2.getZW(); + } else { + final int tmp = x1; + x1 = x2; + x2 = tmp; + su1 = line2.getSU(); + sv1 = line2.getSV(); + sw1 = line2.getSW(); + zw1 = line2.getZW(); + su2 = line1.getSU(); + sv2 = line1.getSV(); + sw2 = line1.getSW(); + zw2 = line1.getZW(); + } + + final double realWidth = x2 - x1; + final double realX1 = x1; + + if (x1 < renderBuffer.renderMinX) + x1 = renderBuffer.renderMinX; + if (x2 >= renderBuffer.renderMaxX) + x2 = renderBuffer.renderMaxX; + + final int span = x2 - x1; + if (span <= 0) + return; + + if (PROF) { + PROF_SPANS.incrementAndGet(); + PROF_PIXELS.addAndGet(span); + } + + int renderBufferOffset = (y * renderBuffer.width) + x1; + + final double dsu = (su2 - su1) / realWidth; + final double dsv = (sv2 - sv1) / realWidth; + final double dsw = (sw2 - sw1) / realWidth; + final double dzw = (zw2 - zw1) / realWidth; + + // Depth margin (polygon offset): fragments within dzMargin world + // units of the stored depth resolve coherently instead of + // z-fighting per pixel — near-coplanar surface pairs (kit-bashed + // wall pieces, draped decals, LOD shells). The queue is + // back-to-front (painter, Z descending), so WITHIN the window + // the LATER (nearer) writer must win: the test therefore rejects + // only fragments that are BEHIND the stored depth by more than + // the margin. (The previous "+margin" form made the FIRST — + // i.e. FARTHER — writer win the window, so dirt within margin + // below the road beat the pavement; combined with a per-span + // margin constant that inflates by (z_pixel/z_near)^2 down + // grazing spans, ground leaked through the road at near-horizon + // pitches. Fixed camera, view-dependent holes = impossible for + // a correct z-buffer.) + // The w-space margin is dz*w^2 evaluated PER PIXEL at the + // fragment's own depth. + + double su = su1 + dsu * (x1 - realX1); + double sv = sv1 + dsv * (x1 - realX1); + double sw = sw1 + dsw * (x1 - realX1); + double zw = zw1 + dzw * (x1 - realX1); + + final int[] texPixels = textureBitmap.pixels; + final int texW = textureBitmap.width; + final int texH = textureBitmap.height; + final int texWMinus1 = texW - 1; + final int texHMinus1 = texH - 1; + final int[] renderBufferPixels = renderBuffer.pixels; + final float[] depth = renderBuffer.depth; + // Alpha pass (depthPass 2): depth-test but never depth-write, + // so cutout foliage cannot occlude later fragments + final boolean writeDepth = renderBuffer.depthPass != 2; + // see drawHorizontalLine: null texture (unit tests) = clamp + final boolean wrap = texture != null && texture.wrap; + + // Same adaptive-subdivision ladder as the painter variant + final double ue1 = su1 / sw1; + final double ue2 = su2 / sw2; + final double ve1 = sv1 / sw1; + final double ve2 = sv2 / sw2; + final double wRatio = Math.max(sw1, sw2) / Math.min(sw1, sw2); + final double texelRate = Math.max(Math.abs(ue2 - ue1), Math.abs(ve2 - ve1)) + / realWidth * wRatio; + final double k = Math.abs(dsw) / Math.min(sw1, sw2); + final double curvature = texelRate * k; + final int interval = curvature < 0.5 / (16 * 16) ? PERSPECTIVE_CORRECTION_INTERVAL + : curvature < 0.5 / (8 * 8) ? 8 + : curvature < 0.5 / (4 * 4) ? 4 + : curvature < 0.5 / (2 * 2) ? 2 : 1; + final double invInterval = 1d / interval; + + int done = 0; + double invW = 1d / sw; + double tx = su * invW; + double ty = sv * invW; + while (done < span) { + final int block = Math.min(interval, span - done); + + su += dsu * block; + sv += dsv * block; + sw += dsw * block; + final double invWNext = 1d / sw; + final double txNext = su * invWNext; + final double tyNext = sv * invWNext; + + final double invBlock = block == interval ? invInterval : 1d / block; + final double txStep = (txNext - tx) * invBlock; + final double tyStep = (tyNext - ty) * invBlock; + + for (int i = 0; i < block; i++) { + if (zw > depth[renderBufferOffset] - RenderingContext.DEPTH_MARGIN_DZ * zw * zw) { + int itx = (int) tx; + int ity = (int) ty; + + if (wrap) { + itx = Math.floorMod(itx, texW); + ity = Math.floorMod(ity, texH); + } else { + if (itx < 0) itx = 0; + else if (itx > texWMinus1) itx = texWMinus1; + + if (ity < 0) ity = 0; + else if (ity > texHMinus1) ity = texHMinus1; + } + + final int srcPixel = texPixels[ity * texW + itx]; + final int srcAlpha = (srcPixel >> 24) & 0xff; + + if (srcAlpha == 255) { + renderBufferPixels[renderBufferOffset] = srcPixel; + if (writeDepth) + depth[renderBufferOffset] = (float) zw; + } else if (srcAlpha != 0) { + // Translucent: blend, but do NOT write depth — + // translucency must not occlude later fragments + final int destPixel = renderBufferPixels[renderBufferOffset]; + final int destR = (destPixel >> 16) & 0xff; + final int destG = (destPixel >> 8) & 0xff; + final int destB = destPixel & 0xff; + + final int r = destR + ((srcAlpha * (((srcPixel >> 16) & 0xff) - destR) - destR) >> 8); + final int g = destG + ((srcAlpha * (((srcPixel >> 8) & 0xff) - destG) - destG) >> 8); + final int b = destB + ((srcAlpha * ((srcPixel & 0xff) - destB) - destB) >> 8); + + renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b; + } + } + + tx += txStep; + ty += tyStep; + zw += dzw; + renderBufferOffset++; + } + + tx = txNext; + ty = tyNext; + + done += block; + } + } + + @Override + public void paint(final RenderingContext renderBuffer) { + // Near-plane clip output takes precedence: a straddling triangle + // clips to a triangle or a quad (one corner cut off). The quad is + // painted as a 2-triangle fan — the clip of a convex polygon stays + // convex, so fan triangulation is exact. Clipped vertices carry + // UVs interpolated in 3D at the cut, which is exactly what the + // perspective-correct path expects of a point on the edge. + final List clipped = clippedVertices(renderBuffer); + if (clipped == null) { + paintTriangle(renderBuffer, vertices.get(0), vertices.get(1), vertices.get(2)); + return; + } + for (int i = 1; i + 1 < clipped.size(); i++) { + paintTriangle(renderBuffer, clipped.get(0), clipped.get(i), clipped.get(i + 1)); + } + } + + /** + * Renders one textured triangle defined by the given vertices (either + * the shape's own three, or a fan triple from the near-plane-clipped + * loop). + * + *

This method performs:

+ *
    + *
  • Backface culling check (if enabled)
  • + *
  • Mouse interaction detection
  • + *
  • Mipmap level selection based on screen coverage
  • + *
  • Scanline rasterization with texture sampling
  • + *
+ * + * @param renderBuffer the rendering context containing the pixel buffer + * @param v1 the first triangle vertex + * @param v2 the second triangle vertex + * @param v3 the third triangle vertex + */ + private void paintTriangle(final RenderingContext renderBuffer, + final Vertex v1, final Vertex v2, final Vertex v3) { + + final Point2D projectedPoint1 = v1.onScreenCoordinate(renderBuffer); + final Point2D projectedPoint2 = v2.onScreenCoordinate(renderBuffer); + final Point2D projectedPoint3 = v3.onScreenCoordinate(renderBuffer); + + if (mouseInteractionController != null) + if (renderBuffer.getMouseEvent() != null) + if (pointWithinPolygon( + renderBuffer.getMouseEvent().coordinate, projectedPoint1, projectedPoint2, projectedPoint3)) { + final double[] uv = textureCoordinateAt( + renderBuffer.getMouseEvent().coordinate, + projectedPoint1, projectedPoint2, projectedPoint3, + v1, v2, v3, renderBuffer); + renderBuffer.setCurrentObjectUnderMouseCursor( + mouseInteractionController, uv[0], uv[1]); + } + + // Show polygon boundaries (for debugging) + if (renderBuffer.developerTools != null && renderBuffer.developerTools.showPolygonBorders) + showBorders(renderBuffer); + + // Keep double precision to eliminate T-junction gaps from truncation errors + final double y1 = projectedPoint1.y; + final double y2 = projectedPoint2.y; + final double y3 = projectedPoint3.y; + + // Find top-most point (use ceil to include all pixels triangle touches) + int yTop = (int) Math.ceil(Math.min(y1, Math.min(y2, y3))); + if (yTop < 0) yTop = 0; + + // Find bottom-most point (use floor to include all pixels triangle touches) + int yBottom = (int) Math.floor(Math.max(y1, Math.max(y2, y3))); + if (yBottom >= renderBuffer.height) yBottom = renderBuffer.height - 1; + + // Clamp to render Y bounds (use renderMaxY - 1 because loop is inclusive) + yTop = Math.max(yTop, renderBuffer.renderMinY); + yBottom = Math.min(yBottom, renderBuffer.renderMaxY - 1); + if (yTop > yBottom) { + if (PROF) + PROF_OFFY.incrementAndGet(); + return; + } + + // Snapshot the texture reference for the whole paint: the GI system + // may swap the composite lightmap on another thread mid-frame. + final Texture texture = this.texture; + + final double edge12 = projectedPoint1.getDistanceTo(projectedPoint2); + final double edge13 = projectedPoint1.getDistanceTo(projectedPoint3); + final double edge23 = projectedPoint2.getDistanceTo(projectedPoint3); + final double totalVisibleDistance = edge12 + edge13 + edge23; + + final double scaleFactor = (totalVisibleDistance / totalTextureDistance) * 1.2d; + + // SDF text/vector-art path: coverage comes from the distance + // field, not from stored coverage, so the mipmap chain (which + // trades sharpness for alias-freedom) is bypassed entirely. + if (texture.isSdf()) { + paintSdf(yTop, yBottom, renderBuffer, + projectedPoint1, projectedPoint2, projectedPoint3, scaleFactor, + v1, v2, v3); + return; + } + + paintFlat(renderBuffer, texture, backfaceCulling, + projectedPoint1, projectedPoint2, projectedPoint3, + v1.textureCoordinate, v2.textureCoordinate, v3.textureCoordinate, + v1.transformedCoordinate(renderBuffer).z, + v2.transformedCoordinate(renderBuffer).z, + v3.transformedCoordinate(renderBuffer).z, + totalTextureDistance); + } + + /** + * Shared rasterization core for one textured triangle whose vertices + * are already in screen space. Called both by the object-backed path + * ({@link #paintTriangle}) and by {@code TriangleMeshBlock} handles, + * whose vertex data lives in flat per-block arrays instead of + * {@link Vertex} objects — the math here is identical either way, + * keeping both paths bit-exact. + * + *

Mouse interaction and debug borders stay in the object path + * (mesh blocks do not support picking). SDF textures are rejected: + * mesh blocks never carry them (enforced at block build time).

+ * + * @param renderBuffer the rendering context containing the pixel buffer + * @param texture the texture to sample + * @param backfaceCulling whether to cull counter-clockwise triangles + * @param projectedPoint1 screen-space vertex 1 + * @param projectedPoint2 screen-space vertex 2 + * @param projectedPoint3 screen-space vertex 3 + * @param texturePoint1 UV (primary-texture pixels) of vertex 1 + * @param texturePoint2 UV of vertex 2 + * @param texturePoint3 UV of vertex 3 + * @param z1 camera-space depth of vertex 1 + * @param z2 camera-space depth of vertex 2 + * @param z3 camera-space depth of vertex 3 + * @param totalTextureDistance UV perimeter for mipmap selection. For a + * near-plane-clipped fan this is the + * ORIGINAL triangle's perimeter (the clip + * does not change the texture's texel + * density), matching the object path. + */ + void paintFlat(final RenderingContext renderBuffer, + final Texture texture, + final boolean backfaceCulling, + final Point2D projectedPoint1, + final Point2D projectedPoint2, + final Point2D projectedPoint3, + final Point2D texturePoint1, + final Point2D texturePoint2, + final Point2D texturePoint3, + final double z1, final double z2, final double z3, + final double totalTextureDistance) { + + // Z-buffer two-pass classification: opaque-class triangles + // paint in pass 1 (depth test + write), alpha-class in pass 2 + // (depth test, no write) — see RenderAggregator.paintSorted. + final boolean alphaClass = texture.isSdf() || texture.hasAlpha; + if ((renderBuffer.depthPass == 1) == alphaClass) + return; + + if (PROF) { + PROF_TRIS.incrementAndGet(); + if ((projectedPoint2.x - projectedPoint1.x) + * (projectedPoint3.y - projectedPoint1.y) + - (projectedPoint3.x - projectedPoint1.x) + * (projectedPoint2.y - projectedPoint1.y) >= 0) + PROF_BACKFACE.incrementAndGet(); + final double bw = Math.max(projectedPoint1.x, Math.max( + projectedPoint2.x, projectedPoint3.x)) + - Math.min(projectedPoint1.x, Math.min( + projectedPoint2.x, projectedPoint3.x)); + final double bh = Math.max(projectedPoint1.y, Math.max( + projectedPoint2.y, projectedPoint3.y)) + - Math.min(projectedPoint1.y, Math.min( + projectedPoint2.y, projectedPoint3.y)); + if (bw * bh < 4) + PROF_TINY.incrementAndGet(); + } + + if (backfaceCulling) { + final double signedArea = (projectedPoint2.x - projectedPoint1.x) + * (projectedPoint3.y - projectedPoint1.y) + - (projectedPoint3.x - projectedPoint1.x) + * (projectedPoint2.y - projectedPoint1.y); + if (signedArea >= 0) + return; + } + + // Keep double precision to eliminate T-junction gaps from truncation errors + final double y1 = projectedPoint1.y; + final double y2 = projectedPoint2.y; + final double y3 = projectedPoint3.y; + + // Find top-most point (use ceil to include all pixels triangle touches) + int yTop = (int) Math.ceil(Math.min(y1, Math.min(y2, y3))); + if (yTop < 0) yTop = 0; + + // Find bottom-most point (use floor to include all pixels triangle touches) + int yBottom = (int) Math.floor(Math.max(y1, Math.max(y2, y3))); + if (yBottom >= renderBuffer.height) yBottom = renderBuffer.height - 1; + + // Clamp to render Y bounds (use renderMaxY - 1 because loop is inclusive) + yTop = Math.max(yTop, renderBuffer.renderMinY); + yBottom = Math.min(yBottom, renderBuffer.renderMaxY - 1); + if (yTop > yBottom) { + if (PROF) + PROF_OFFY.incrementAndGet(); + return; + } + + if (texture.isSdf()) + throw new IllegalStateException( + "SDF textures are not supported in mesh blocks"); + + final double edge12 = projectedPoint1.getDistanceTo(projectedPoint2); + final double edge13 = projectedPoint1.getDistanceTo(projectedPoint3); + final double edge23 = projectedPoint2.getDistanceTo(projectedPoint3); + final double totalVisibleDistance = edge12 + edge13 + edge23; + + final double scaleFactor = (totalVisibleDistance / totalTextureDistance) * 1.2d; + + final TextureBitmap mipmap = texture.getMipmapForScale(scaleFactor); + + if (perspectiveCorrectionEnabled) { + if (z1 > PERSPECTIVE_MIN_Z && z2 > PERSPECTIVE_MIN_Z && z3 > PERSPECTIVE_MIN_Z) { + // Affine mapping is within half a texel of exact + // perspective for small or nearly-flat triangles, making + // the perspective setup pointless for them: the midpoint + // error of affine vs exact is ~= texelSpan*(zRatio-1)/4 + // where texelSpan is the texture range (in selected-mip + // texels) the triangle covers — NOT its pixel size (a + // triangle can map many texels into few pixels; measured + // 2026-09-06: a 4px span with a 56-texel range deviated 3 + // texels under the pixel-size rule). Distant clusters of + // small triangles render affine. + // Verified by TexturedTrianglePerspectiveTest#affineWithinHalfTexelBound. + final double mf0 = mipmap.multiplicationFactor; + final double tu1 = texturePoint1.x * mf0; + final double tv1 = texturePoint1.y * mf0; + final double tu2 = texturePoint2.x * mf0; + final double tv2 = texturePoint2.y * mf0; + final double tu3 = texturePoint3.x * mf0; + final double tv3 = texturePoint3.y * mf0; + final double texelSpan = Math.max( + Math.max(Math.abs(tu2 - tu1), Math.abs(tv2 - tv1)), + Math.max( + Math.max(Math.abs(tu3 - tu1), Math.abs(tv3 - tv1)), + Math.max(Math.abs(tu3 - tu2), Math.abs(tv3 - tv2)))); + final double zMin = Math.min(z1, Math.min(z2, z3)); + final double zMax = Math.max(z1, Math.max(z2, z3)); + if (texelSpan * (zMax / zMin - 1d) < 2d) { + paintAffine(yTop, yBottom, mipmap, renderBuffer, + projectedPoint1, projectedPoint2, projectedPoint3, + texturePoint1, texturePoint2, texturePoint3, + z1, z2, z3); + return; + } + + // Quake-style perspective-correct mapping: interpolate + // (u/z, v/z, 1/z), which are linear in screen space, and + // recover exact (u, v) every PERSPECTIVE_CORRECTION_INTERVAL + // pixels in the scanline. The mipmap multiplication factor + // is folded into the gradients here, so the scanline works + // directly in texture pixel units. + final double mf = mipmap.multiplicationFactor; + + final double sw1 = 1d / z1; + final double sw2 = 1d / z2; + final double sw3 = 1d / z3; + + final double su1 = texturePoint1.x * mf * sw1; + final double sv1 = texturePoint1.y * mf * sw1; + final double su2 = texturePoint2.x * mf * sw2; + final double sv2 = texturePoint2.y * mf * sw2; + final double su3 = texturePoint3.x * mf * sw3; + final double sv3 = texturePoint3.y * mf * sw3; + + final PerspectiveBorderInterpolator[] pi = PERSPECTIVE_INTERPOLATORS.get(); + pi[0].setPoints(projectedPoint1, projectedPoint2, su1, sv1, sw1, su2, sv2, sw2); + pi[1].setPoints(projectedPoint1, projectedPoint3, su1, sv1, sw1, su3, sv3, sw3); + pi[2].setPoints(projectedPoint2, projectedPoint3, su2, sv2, sw2, su3, sv3, sw3); + + { + // 1/z rides the same edge interpolation; spans + // depth-test before texturing. + final double zw1 = 1d / z1; + final double zw2 = 1d / z2; + final double zw3 = 1d / z3; + pi[0].setPointsZW(zw1, zw2); + pi[1].setPointsZW(zw1, zw3); + pi[2].setPointsZW(zw2, zw3); + for (int y = yTop; y <= yBottom; y++) { + if (pi[0].containsY(y)) { + if (pi[1].containsY(y)) + drawHorizontalLinePerspectiveZ(pi[0], pi[1], y, renderBuffer, mipmap); + else if (pi[2].containsY(y)) + drawHorizontalLinePerspectiveZ(pi[0], pi[2], y, renderBuffer, mipmap); + } else if (pi[1].containsY(y)) { + if (pi[2].containsY(y)) + drawHorizontalLinePerspectiveZ(pi[1], pi[2], y, renderBuffer, mipmap); + } + } + return; + } + } + } + + paintAffine(yTop, yBottom, mipmap, renderBuffer, + projectedPoint1, projectedPoint2, projectedPoint3, + texturePoint1, texturePoint2, texturePoint3, + z1, z2, z3); + } + + /** + * Computes the perspective-correct texture coordinate at a screen-space + * point known to lie inside the triangle. + * + *

Screen-space barycentric weights are divided by the camera-space z + * of each vertex and renormalized — the same (u/z, v/z, 1/z) math the + * perspective-correct scanline path uses — so the returned coordinate + * matches the texel that was actually painted at that pixel, even at + * steep viewing angles. Texture coordinates are in primary-texture + * pixels (no mipmap factor applied).

+ * + * @return double[2] with {u, v} in primary-texture pixels + */ + private static double[] textureCoordinateAt(final Point2D point, + final Point2D p1, final Point2D p2, final Point2D p3, + final Vertex v1, final Vertex v2, final Vertex v3, + final RenderingContext renderBuffer) { + final double denom = (p2.y - p3.y) * (p1.x - p3.x) + + (p3.x - p2.x) * (p1.y - p3.y); + if (Math.abs(denom) < 1e-9) + // degenerate on screen; the hit pixel is effectively a vertex + return new double[]{v1.textureCoordinate.x, v1.textureCoordinate.y}; + + double w1 = ((p2.y - p3.y) * (point.x - p3.x) + + (p3.x - p2.x) * (point.y - p3.y)) / denom; + double w2 = ((p3.y - p1.y) * (point.x - p3.x) + + (p1.x - p3.x) * (point.y - p3.y)) / denom; + double w3 = 1d - w1 - w2; + + final double z1 = v1.transformedCoordinate(renderBuffer).z; + final double z2 = v2.transformedCoordinate(renderBuffer).z; + final double z3 = v3.transformedCoordinate(renderBuffer).z; + + if (z1 > PERSPECTIVE_MIN_Z && z2 > PERSPECTIVE_MIN_Z + && z3 > PERSPECTIVE_MIN_Z) { + w1 /= z1; + w2 /= z2; + w3 /= z3; + final double sum = w1 + w2 + w3; + w1 /= sum; + w2 /= sum; + w3 /= sum; + } + // near-plane straddlers: plain screen-space barycentric (affine), + // matching the affine fallback path used for painting them + + return new double[]{ + w1 * v1.textureCoordinate.x + w2 * v2.textureCoordinate.x + + w3 * v3.textureCoordinate.x, + w1 * v1.textureCoordinate.y + w2 * v2.textureCoordinate.y + + w3 * v3.textureCoordinate.y}; + } + + /** + * SDF (signed distance field) rendering path. Coverage is not stored + * in the texture; it is re-derived per pixel from a smooth distance + * mask, so edges stay sharp at any magnification and fade to clean + * gray under minification. Layers: {@code texture.primaryBitmap} is + * the background color layer, {@code texture.sdfForeground} the ink + * color layer (both sampled nearest — they are flat per region), + * {@code texture.sdfMask} the distance field (sampled bilinear). + * + *

Minification is handled analytically: the coverage window is + * widened by the screen-space pixel footprint, which gives correct + * area coverage without a mipmap chain.

+ * + * @param scaleFactor the same screen-pixels-per-texel estimate the + * mipmap selection uses (times 1.2) + */ + private void paintSdf(final int yTop, final int yBottom, + final RenderingContext renderBuffer, + final Point2D projectedPoint1, final Point2D projectedPoint2, + final Point2D projectedPoint3, final double scaleFactor, + final Vertex v1, final Vertex v2, final Vertex v3) { + // SDF (text/decal) is alpha-class — it paints in the + // back-to-front alpha pass only, without depth interaction. + if (renderBuffer.depthPass == 1) + return; + // Per-axis screen-space UV gradients (affine estimate — adequate + // for a footprint). Text on an angled plane is minified mostly + // along ONE axis; an isotropic average would blur the axis that + // still has resolution to spare. + double footX; + double footY; + final double ex = projectedPoint2.x - projectedPoint1.x; + final double ey = projectedPoint2.y - projectedPoint1.y; + final double fx3 = projectedPoint3.x - projectedPoint1.x; + final double fy3 = projectedPoint3.y - projectedPoint1.y; + final double denom = ex * fy3 - fx3 * ey; + if (Math.abs(denom) > 1e-9) { + final double u1 = v1.textureCoordinate.x; + final double vv1 = v1.textureCoordinate.y; + final double du21 = v2.textureCoordinate.x - u1; + final double dv21 = v2.textureCoordinate.y - vv1; + final double du31 = v3.textureCoordinate.x - u1; + final double dv31 = v3.textureCoordinate.y - vv1; + final double dudx = (du21 * fy3 - du31 * ey) / denom; + final double dudy = (du31 * ex - du21 * fx3) / denom; + final double dvdx = (dv21 * fy3 - dv31 * ey) / denom; + final double dvdy = (dv31 * ex - dv21 * fx3) / denom; + footX = Math.hypot(dudx, dvdx); + footY = Math.hypot(dudy, dvdy); + } else { + footX = footY = 1.2d / scaleFactor; + } + + // The coverage window follows the SHARPEST axis: one screen pixel + // spans texelsPerPixel texels along it, i.e. + // texelsPerPixel/(2*spread) of the normalized mask range; aaK + // converts a mask sample (0..255, edge at 127.5) into fixed-point + // coverage in [0, 256]: cov = (127.5 - d)*aaK + 128. + final double maxFootprint = Math.max(footX, footY); + double texelsPerPixel = Math.max(Math.min(footX, footY), 0.01d); + if (maxFootprint > 1d) { + texelsPerPixel /= SDF_SHARPEN; + } + final double aaK = (2d * texture.sdfSpreadTexels) / texelsPerPixel / 255d * 256d; + + // No mip chain for SDF layers. A distance field's edge gradient + // spans just 2 texels, so a half/quarter-res mask visibly melts + // glyph edges — and because the two triangles of a rectangle get + // slightly different perspective footprints, they crossed mip + // thresholds at different distances, producing a hard diagonal + // quality split plus sudden blur steps while dollying (observed + // 2026-09-06). Sampling the primary field costs some bandwidth + // under minification, but text surfaces are small and the + // per-pixel sample count is what matters. Quality then degrades + // smoothly with distance instead of in steps. + final TextureBitmap mask = texture.sdfMask; + final TextureBitmap fg = texture.sdfForeground; + final TextureBitmap bg = texture.primaryBitmap; + final double mf = mask.multiplicationFactor; + + // Under minification, area-correct coverage reads as low-contrast + // gray haze. Two perceptual corrections (A/B-tuned 2026-09-06 on + // far+angled text): SDF_SHARPEN narrows the coverage window below + // one pixel (kills the haze halo, keeps edges crisp at the cost + // of a little shimmer), and a mild coverage gamma < 1 (stem + // darkening, the small-ppm font rasterizer trick) keeps thin + // strokes present. + // Knobs: -De3d.sdf.gamma=1.4 forces a fixed gamma (0 = auto), + // -De3d.sdf.sharpen=1 restores the pixel-exact window. + final int[] covLut; + final double gamma = SDF_GAMMA != 0 ? SDF_GAMMA + : Math.max(0.75d, 1d - 0.08d * (Math.log(maxFootprint) / Math.log(2d))); + if (gamma != 1d && maxFootprint > 1d) { + covLut = new int[257]; + for (int i = 0; i <= 256; i++) { + covLut[i] = Math.min(256, (int) (256d * Math.pow(i / 256d, gamma))); + } + } else { + covLut = null; + } + + boolean usePerspective = false; + double su1 = 0, sv1 = 0, sw1 = 0; + double su2 = 0, sv2 = 0, sw2 = 0; + double su3 = 0, sv3 = 0, sw3 = 0; + if (perspectiveCorrectionEnabled) { + final double z1 = v1.transformedCoordinate(renderBuffer).z; + final double z2 = v2.transformedCoordinate(renderBuffer).z; + final double z3 = v3.transformedCoordinate(renderBuffer).z; + if (z1 > PERSPECTIVE_MIN_Z && z2 > PERSPECTIVE_MIN_Z && z3 > PERSPECTIVE_MIN_Z) { + // Same affine-sufficiency test as the coverage path + // (mask/fg/bg are all primary resolution, mf = 1). + final double tu1 = v1.textureCoordinate.x; + final double tv1 = v1.textureCoordinate.y; + final double tu2 = v2.textureCoordinate.x; + final double tv2 = v2.textureCoordinate.y; + final double tu3 = v3.textureCoordinate.x; + final double tv3 = v3.textureCoordinate.y; + final double texelSpan = Math.max( + Math.max(Math.abs(tu2 - tu1), Math.abs(tv2 - tv1)), + Math.max( + Math.max(Math.abs(tu3 - tu1), Math.abs(tv3 - tv1)), + Math.max(Math.abs(tu3 - tu2), Math.abs(tv3 - tv2)))); + final double zMin = Math.min(z1, Math.min(z2, z3)); + final double zMax = Math.max(z1, Math.max(z2, z3)); + usePerspective = texelSpan * (zMax / zMin - 1d) >= 2d; + if (usePerspective) { + sw1 = 1d / z1; + sw2 = 1d / z2; + sw3 = 1d / z3; + su1 = tu1 * mf * sw1; + sv1 = tv1 * mf * sw1; + su2 = tu2 * mf * sw2; + sv2 = tv2 * mf * sw2; + su3 = tu3 * mf * sw3; + sv3 = tv3 * mf * sw3; + } + } + } + + if (SDF_DEBUG && (usePerspective != sdfDebugLastPerspective + || Math.abs(footY - sdfDebugLastFootY) > 0.5)) { + sdfDebugLastPerspective = usePerspective; + sdfDebugLastFootY = footY; + System.err.printf("[SDF] perspective=%b footX=%.2f footY=%.2f aaK=%.3f%n", + usePerspective, footX, footY, aaK); + } + + if (usePerspective) { + final PerspectiveBorderInterpolator[] pi = PERSPECTIVE_INTERPOLATORS.get(); + pi[0].setPoints(projectedPoint1, projectedPoint2, su1, sv1, sw1, su2, sv2, sw2); + pi[1].setPoints(projectedPoint1, projectedPoint3, su1, sv1, sw1, su3, sv3, sw3); + pi[2].setPoints(projectedPoint2, projectedPoint3, su2, sv2, sw2, su3, sv3, sw3); + + for (int y = yTop; y <= yBottom; y++) { + if (pi[0].containsY(y)) { + if (pi[1].containsY(y)) + drawHorizontalLinePerspectiveSdf(pi[0], pi[1], y, renderBuffer, mask, fg, bg, aaK, covLut); + else if (pi[2].containsY(y)) + drawHorizontalLinePerspectiveSdf(pi[0], pi[2], y, renderBuffer, mask, fg, bg, aaK, covLut); + } else if (pi[1].containsY(y)) { + if (pi[2].containsY(y)) + drawHorizontalLinePerspectiveSdf(pi[1], pi[2], y, renderBuffer, mask, fg, bg, aaK, covLut); + } + } + return; + } + + final PolygonBorderInterpolator[] interpolators = INTERPOLATORS.get(); + final PolygonBorderInterpolator pbi1 = interpolators[0]; + final PolygonBorderInterpolator pbi2 = interpolators[1]; + final PolygonBorderInterpolator pbi3 = interpolators[2]; + + pbi1.setPoints(projectedPoint1, projectedPoint2, v1.textureCoordinate, v2.textureCoordinate); + pbi2.setPoints(projectedPoint1, projectedPoint3, v1.textureCoordinate, v3.textureCoordinate); + pbi3.setPoints(projectedPoint2, projectedPoint3, v2.textureCoordinate, v3.textureCoordinate); + + for (int y = yTop; y <= yBottom; y++) { + if (pbi1.containsY(y)) { + if (pbi2.containsY(y)) + drawHorizontalLineSdf(pbi1, pbi2, y, renderBuffer, mask, fg, bg, aaK, mf, covLut); + else if (pbi3.containsY(y)) + drawHorizontalLineSdf(pbi1, pbi3, y, renderBuffer, mask, fg, bg, aaK, mf, covLut); + } else if (pbi2.containsY(y)) { + if (pbi3.containsY(y)) + drawHorizontalLineSdf(pbi2, pbi3, y, renderBuffer, mask, fg, bg, aaK, mf, covLut); + } + } + } + + /** + * SDF scanline, affine mapping. Texture coordinates are scaled by the + * selected mip's multiplication factor (all layers share one mip + * level, so one factor covers mask and both color layers). + */ + private void drawHorizontalLineSdf(final PolygonBorderInterpolator line1, + final PolygonBorderInterpolator line2, final int y, + final RenderingContext renderBuffer, + final TextureBitmap mask, final TextureBitmap fg, + final TextureBitmap bg, final double aaK, + final double mf, final int[] covLut) { + line1.setCurrentY(y); + line2.setCurrentY(y); + + int x1 = line1.getX(); + int x2 = line2.getX(); + + final double tx1, ty1, tx2, ty2; + if (x1 <= x2) { + tx1 = line1.getTX() * mf; + ty1 = line1.getTY() * mf; + tx2 = line2.getTX() * mf; + ty2 = line2.getTY() * mf; + } else { + final int tmp = x1; + x1 = x2; + x2 = tmp; + tx1 = line2.getTX() * mf; + ty1 = line2.getTY() * mf; + tx2 = line1.getTX() * mf; + ty2 = line1.getTY() * mf; + } + + final double realWidth = x2 - x1; + final double realX1 = x1; + + if (x1 < renderBuffer.renderMinX) + x1 = renderBuffer.renderMinX; + if (x2 >= renderBuffer.renderMaxX) + x2 = renderBuffer.renderMaxX; + + int renderBufferOffset = (y * renderBuffer.width) + x1; + final int[] renderBufferPixels = renderBuffer.pixels; + + final double txStep = (tx2 - tx1) / realWidth; + final double tyStep = (ty2 - ty1) / realWidth; + + double tx = tx1 + txStep * (x1 - realX1); + double ty = ty1 + tyStep * (x1 - realX1); + + final int[] maskPixels = mask.pixels; + final int[] fgPixels = fg.pixels; + final int[] bgPixels = bg.pixels; + final int mw = mask.width; + final int mh = mask.height; + final double bilinearCapX = mw - 1.0001d; + final double bilinearCapY = mh - 1.0001d; + final int mw1 = mw - 1; + final int mh1 = mh - 1; + + for (int x = x1; x < x2; x++) { + // Fixed-point bilinear distance fetch (8.8 fractions) + final double ctx = tx < 0 ? 0 : Math.min(tx, bilinearCapX); + final double cty = ty < 0 ? 0 : Math.min(ty, bilinearCapY); + final int x0 = (int) ctx; + final int y0 = (int) cty; + final int fx = (int) ((ctx - x0) * 256); + final int fy = (int) ((cty - y0) * 256); + final int row0 = y0 * mw + x0; + final int row1 = row0 + mw; + final int m00 = (maskPixels[row0] >> 16) & 0xff; + final int m10 = (maskPixels[row0 + 1] >> 16) & 0xff; + final int m01 = (maskPixels[row1] >> 16) & 0xff; + final int m11 = (maskPixels[row1 + 1] >> 16) & 0xff; + final int d = (m00 * (256 - fx) * (256 - fy) + m10 * fx * (256 - fy) + + m01 * (256 - fx) * fy + m11 * fx * fy) >> 16; + + int cov = (int) ((127.5d - d) * aaK + 128d); + if (cov < 0) cov = 0; + else if (cov > 256) cov = 256; + if (covLut != null) cov = covLut[cov]; + + int itx = (int) tx; + int ity = (int) ty; + if (itx < 0) itx = 0; + else if (itx > mw1) itx = mw1; + if (ity < 0) ity = 0; + else if (ity > mh1) ity = mh1; + final int addr = ity * mw + itx; + + final int srcPixel; + if (cov <= 0) { + srcPixel = bgPixels[addr]; + } else if (cov >= 256) { + srcPixel = fgPixels[addr]; + } else { + final int bgP = bgPixels[addr]; + final int fgP = fgPixels[addr]; + final int a = (bgP >>> 24) + ((((int) (fgP >>> 24) - (bgP >>> 24)) * cov) >> 8); + final int r = ((bgP >> 16) & 0xff) + (((((fgP >> 16) & 0xff) - ((bgP >> 16) & 0xff)) * cov) >> 8); + final int g = ((bgP >> 8) & 0xff) + (((((fgP >> 8) & 0xff) - ((bgP >> 8) & 0xff)) * cov) >> 8); + final int b = (bgP & 0xff) + ((((fgP & 0xff) - (bgP & 0xff)) * cov) >> 8); + srcPixel = (a << 24) | (r << 16) | (g << 8) | b; + } + + final int srcAlpha = (srcPixel >> 24) & 0xff; + if (srcAlpha == 255) { + renderBufferPixels[renderBufferOffset] = srcPixel; + } else if (srcAlpha != 0) { + final int destPixel = renderBufferPixels[renderBufferOffset]; + final int destR = (destPixel >> 16) & 0xff; + final int destG = (destPixel >> 8) & 0xff; + final int destB = destPixel & 0xff; + final int r = destR + ((srcAlpha * (((srcPixel >> 16) & 0xff) - destR) - destR) >> 8); + final int g = destG + ((srcAlpha * (((srcPixel >> 8) & 0xff) - destG) - destG) >> 8); + final int b = destB + ((srcAlpha * ((srcPixel & 0xff) - destB) - destB) >> 8); + renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b; + } + + tx += txStep; + ty += tyStep; + renderBufferOffset++; + } + } + + /** + * SDF scanline with Quake-style subdivided perspective correction — + * same stepping structure as {@link #drawHorizontalLinePerspective}, + * with the coverage fetch replaced by the distance-field evaluation. + */ + private void drawHorizontalLinePerspectiveSdf( + final PerspectiveBorderInterpolator line1, + final PerspectiveBorderInterpolator line2, + final int y, + final RenderingContext renderBuffer, + final TextureBitmap mask, final TextureBitmap fg, + final TextureBitmap bg, final double aaK, final int[] covLut) { + + line1.setCurrentY(y); + line2.setCurrentY(y); + + int x1 = line1.getX(); + int x2 = line2.getX(); + + final double su1, sv1, sw1; + final double su2, sv2, sw2; + + if (x1 <= x2) { + su1 = line1.getSU(); + sv1 = line1.getSV(); + sw1 = line1.getSW(); + su2 = line2.getSU(); + sv2 = line2.getSV(); + sw2 = line2.getSW(); + } else { + final int tmp = x1; + x1 = x2; + x2 = tmp; + su1 = line2.getSU(); + sv1 = line2.getSV(); + sw1 = line2.getSW(); + su2 = line1.getSU(); + sv2 = line1.getSV(); + sw2 = line1.getSW(); + } + + final double realWidth = x2 - x1; + final double realX1 = x1; + + if (x1 < renderBuffer.renderMinX) + x1 = renderBuffer.renderMinX; + if (x2 >= renderBuffer.renderMaxX) + x2 = renderBuffer.renderMaxX; + + final int span = x2 - x1; + if (span <= 0) + return; + + int renderBufferOffset = (y * renderBuffer.width) + x1; + + final double dsu = (su2 - su1) / realWidth; + final double dsv = (sv2 - sv1) / realWidth; + final double dsw = (sw2 - sw1) / realWidth; + + double su = su1 + dsu * (x1 - realX1); + double sv = sv1 + dsv * (x1 - realX1); + double sw = sw1 + dsw * (x1 - realX1); + + final int[] renderBufferPixels = renderBuffer.pixels; + + final int[] maskPixels = mask.pixels; + final int[] fgPixels = fg.pixels; + final int[] bgPixels = bg.pixels; + final int mw = mask.width; + final int mh = mask.height; + final double bilinearCapX = mw - 1.0001d; + final double bilinearCapY = mh - 1.0001d; + final int mw1 = mw - 1; + final int mh1 = mh - 1; + + // Same adaptive-interval ladder as the coverage path. + final double ue1 = su1 / sw1; + final double ue2 = su2 / sw2; + final double ve1 = sv1 / sw1; + final double ve2 = sv2 / sw2; + final double wRatio = Math.max(sw1, sw2) / Math.min(sw1, sw2); + final double texelRate = Math.max(Math.abs(ue2 - ue1), Math.abs(ve2 - ve1)) + / realWidth * wRatio; + final double k = Math.abs(dsw) / Math.min(sw1, sw2); + final double curvature = texelRate * k; + final int interval = curvature < 0.5 / (16 * 16) ? PERSPECTIVE_CORRECTION_INTERVAL + : curvature < 0.5 / (8 * 8) ? 8 + : curvature < 0.5 / (4 * 4) ? 4 + : curvature < 0.5 / (2 * 2) ? 2 : 1; + final double invInterval = 1d / interval; + + int done = 0; + double invW = 1d / sw; + double tx = su * invW; + double ty = sv * invW; + while (done < span) { + final int block = Math.min(interval, span - done); + + su += dsu * block; + sv += dsv * block; + sw += dsw * block; + final double invWNext = 1d / sw; + final double txNext = su * invWNext; + final double tyNext = sv * invWNext; + + final double invBlock = block == interval ? invInterval : 1d / block; + final double txStep = (txNext - tx) * invBlock; + final double tyStep = (tyNext - ty) * invBlock; + + for (int i = 0; i < block; i++) { + // Fixed-point bilinear distance fetch (8.8 fractions) + final double ctx = tx < 0 ? 0 : Math.min(tx, bilinearCapX); + final double cty = ty < 0 ? 0 : Math.min(ty, bilinearCapY); + final int x0 = (int) ctx; + final int y0 = (int) cty; + final int fx = (int) ((ctx - x0) * 256); + final int fy = (int) ((cty - y0) * 256); + final int row0 = y0 * mw + x0; + final int row1 = row0 + mw; + final int m00 = (maskPixels[row0] >> 16) & 0xff; + final int m10 = (maskPixels[row0 + 1] >> 16) & 0xff; + final int m01 = (maskPixels[row1] >> 16) & 0xff; + final int m11 = (maskPixels[row1 + 1] >> 16) & 0xff; + final int d = (m00 * (256 - fx) * (256 - fy) + m10 * fx * (256 - fy) + + m01 * (256 - fx) * fy + m11 * fx * fy) >> 16; + + int cov = (int) ((127.5d - d) * aaK + 128d); + if (cov < 0) cov = 0; + else if (cov > 256) cov = 256; + if (covLut != null) cov = covLut[cov]; + + int itx = (int) tx; + int ity = (int) ty; + if (itx < 0) itx = 0; + else if (itx > mw1) itx = mw1; + if (ity < 0) ity = 0; + else if (ity > mh1) ity = mh1; + final int addr = ity * mw + itx; + + final int srcPixel; + if (cov <= 0) { + srcPixel = bgPixels[addr]; + } else if (cov >= 256) { + srcPixel = fgPixels[addr]; + } else { + final int bgP = bgPixels[addr]; + final int fgP = fgPixels[addr]; + final int a = (bgP >>> 24) + ((((int) (fgP >>> 24) - (bgP >>> 24)) * cov) >> 8); + final int r = ((bgP >> 16) & 0xff) + (((((fgP >> 16) & 0xff) - ((bgP >> 16) & 0xff)) * cov) >> 8); + final int g = ((bgP >> 8) & 0xff) + (((((fgP >> 8) & 0xff) - ((bgP >> 8) & 0xff)) * cov) >> 8); + final int b = (bgP & 0xff) + ((((fgP & 0xff) - (bgP & 0xff)) * cov) >> 8); + srcPixel = (a << 24) | (r << 16) | (g << 8) | b; + } + + final int srcAlpha = (srcPixel >> 24) & 0xff; + if (srcAlpha == 255) { + renderBufferPixels[renderBufferOffset] = srcPixel; + } else if (srcAlpha != 0) { + final int destPixel = renderBufferPixels[renderBufferOffset]; + final int destR = (destPixel >> 16) & 0xff; + final int destG = (destPixel >> 8) & 0xff; + final int destB = destPixel & 0xff; + final int r = destR + ((srcAlpha * (((srcPixel >> 16) & 0xff) - destR) - destR) >> 8); + final int g = destG + ((srcAlpha * (((srcPixel >> 8) & 0xff) - destG) - destG) >> 8); + final int b = destB + ((srcAlpha * ((srcPixel & 0xff) - destB) - destB) >> 8); + renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b; + } + + tx += txStep; + ty += tyStep; + renderBufferOffset++; + } + + done += block; + } + } + + /** + * Affine texture mapping (u, v linear in screen space). Used for + * near-plane straddlers and for triangles small/flat enough that + * affine is within half a texel of exact perspective mapping. + */ + private void paintAffine(final int yTop, final int yBottom, + final TextureBitmap mipmap, + final RenderingContext renderBuffer, + final Point2D projectedPoint1, final Point2D projectedPoint2, + final Point2D projectedPoint3, + final Point2D texturePoint1, final Point2D texturePoint2, + final Point2D texturePoint3, + final double z1, final double z2, final double z3) { + final PolygonBorderInterpolator[] interpolators = INTERPOLATORS.get(); + final PolygonBorderInterpolator pbi1 = interpolators[0]; + final PolygonBorderInterpolator pbi2 = interpolators[1]; + final PolygonBorderInterpolator pbi3 = interpolators[2]; + + pbi1.setPoints(projectedPoint1, projectedPoint2, texturePoint1, texturePoint2); + pbi2.setPoints(projectedPoint1, projectedPoint3, texturePoint1, texturePoint3); + pbi3.setPoints(projectedPoint2, projectedPoint3, texturePoint2, texturePoint3); + + final double zw1 = 1d / z1; + final double zw2 = 1d / z2; + final double zw3 = 1d / z3; + pbi1.setPointsZW(zw1, zw2); + pbi2.setPointsZW(zw1, zw3); + pbi3.setPointsZW(zw2, zw3); + for (int y = yTop; y <= yBottom; y++) { + if (pbi1.containsY(y)) { + if (pbi2.containsY(y)) + drawHorizontalLineZ(pbi1, pbi2, y, renderBuffer, mipmap); + else if (pbi3.containsY(y)) + drawHorizontalLineZ(pbi1, pbi3, y, renderBuffer, mipmap); + } else if (pbi2.containsY(y)) { + if (pbi3.containsY(y)) + drawHorizontalLineZ(pbi2, pbi3, y, renderBuffer, mipmap); + } + } + + } + + /** + * Z-buffer variant of {@link #drawHorizontalLine}: per-pixel depth + * test (biased 1/z, linear along the span) BEFORE the texture fetch. + * Opaque texels write depth; blended texels write color only. + */ + private void drawHorizontalLineZ(final PolygonBorderInterpolator line1, + final PolygonBorderInterpolator line2, + final int y, + final RenderingContext renderBuffer, + final TextureBitmap textureBitmap) { + + line1.setCurrentY(y); + line2.setCurrentY(y); + + int x1 = line1.getX(); + int x2 = line2.getX(); + + final double tx1, ty1, zw1; + final double tx2, ty2, zw2; + + if (x1 <= x2) { + tx1 = line1.getTX() * textureBitmap.multiplicationFactor; + ty1 = line1.getTY() * textureBitmap.multiplicationFactor; + zw1 = line1.getZW(); + tx2 = line2.getTX() * textureBitmap.multiplicationFactor; + ty2 = line2.getTY() * textureBitmap.multiplicationFactor; + zw2 = line2.getZW(); + } else { + final int tmp = x1; + x1 = x2; + x2 = tmp; + + tx1 = line2.getTX() * textureBitmap.multiplicationFactor; + ty1 = line2.getTY() * textureBitmap.multiplicationFactor; + zw1 = line2.getZW(); + + tx2 = line1.getTX() * textureBitmap.multiplicationFactor; + ty2 = line1.getTY() * textureBitmap.multiplicationFactor; + zw2 = line1.getZW(); + } + + final double realWidth = x2 - x1; + final double realX1 = x1; + + if (x1 < renderBuffer.renderMinX) + x1 = renderBuffer.renderMinX; + + // x2 is exclusive: clamp to renderMaxX (see drawHorizontalLine) + if (x2 >= renderBuffer.renderMaxX) + x2 = renderBuffer.renderMaxX; + + if (PROF) { + PROF_SPANS.incrementAndGet(); + PROF_PIXELS.addAndGet(Math.max(0, x2 - x1)); + } + + int renderBufferOffset = (y * renderBuffer.width) + x1; + final int[] renderBufferPixels = renderBuffer.pixels; + final float[] depth = renderBuffer.depth; + // Alpha pass (depthPass 2): depth-test but never depth-write + final boolean writeDepth = renderBuffer.depthPass != 2; + + final double txStep = (tx2 - tx1) / realWidth; + final double tyStep = (ty2 - ty1) / realWidth; + final double dzw = (zw2 - zw1) / realWidth; + double tx = tx1 + txStep * (x1 - realX1); + double ty = ty1 + tyStep * (x1 - realX1); + double zw = zw1 + dzw * (x1 - realX1); + + final int[] texPixels = textureBitmap.pixels; + final int texW = textureBitmap.width; + final int texH = textureBitmap.height; + final int texWMinus1 = texW - 1; + final int texHMinus1 = texH - 1; + // texture is null in unit tests: clamp (see drawHorizontalLine) + final boolean wrap = texture != null && texture.wrap; + + for (int x = x1; x < x2; x++) { + + if (zw > depth[renderBufferOffset] - RenderingContext.DEPTH_MARGIN_DZ * zw * zw) { + int itx = (int) tx; + int ity = (int) ty; + + if (wrap) { + itx = Math.floorMod(itx, texW); + ity = Math.floorMod(ity, texH); + } else { + if (itx < 0) itx = 0; + else if (itx > texWMinus1) itx = texWMinus1; + + if (ity < 0) ity = 0; + else if (ity > texHMinus1) ity = texHMinus1; + } + + final int srcPixel = texPixels[ity * texW + itx]; + final int srcAlpha = (srcPixel >> 24) & 0xff; + + if (srcAlpha == 255) { + renderBufferPixels[renderBufferOffset] = srcPixel; + if (writeDepth) + depth[renderBufferOffset] = (float) zw; + } else if (srcAlpha != 0) { + // Translucent: blend without writing depth + final int destPixel = renderBufferPixels[renderBufferOffset]; + final int destR = (destPixel >> 16) & 0xff; + final int destG = (destPixel >> 8) & 0xff; + final int destB = destPixel & 0xff; + + final int r = destR + ((srcAlpha * (((srcPixel >> 16) & 0xff) - destR) - destR) >> 8); + final int g = destG + ((srcAlpha * (((srcPixel >> 8) & 0xff) - destG) - destG) >> 8); + final int b = destB + ((srcAlpha * ((srcPixel & 0xff) - destB) - destB) >> 8); + + renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b; + } + } + + tx += txStep; + ty += tyStep; + zw += dzw; + renderBufferOffset++; + } + + } + + /** + * Checks if backface culling is enabled for this triangle. + * + * @return {@code true} if backface culling is enabled + */ + public boolean isBackfaceCullingEnabled() { + return backfaceCulling; + } + + /** + * Enables or disables backface culling for this triangle. + * + * @param backfaceCulling {@code true} to enable backface culling + */ + public void setBackfaceCulling(final boolean backfaceCulling) { + this.backfaceCulling = backfaceCulling; + } + + /** + * Draws the triangle border edges in yellow (for debugging). + * + * @param renderBuffer the rendering context + */ + private void showBorders(final RenderingContext renderBuffer) { + + final Point2D projectedPoint1 = vertices.get(0).onScreenCoordinate(renderBuffer); + final Point2D projectedPoint2 = vertices.get(1).onScreenCoordinate(renderBuffer); + final Point2D projectedPoint3 = vertices.get(2).onScreenCoordinate(renderBuffer); + + final int x1 = (int) projectedPoint1.x; + final int y1 = (int) projectedPoint1.y; + final int x2 = (int) projectedPoint2.x; + final int y2 = (int) projectedPoint2.y; + final int x3 = (int) projectedPoint3.x; + final int y3 = (int) projectedPoint3.y; + + renderBuffer.executeWithGraphics(g -> { + g.setColor(Color.YELLOW); + g.drawLine(x1, y1, x2, y2); + g.drawLine(x3, y3, x2, y2); + g.drawLine(x1, y1, x3, y3); + }); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TriangleMeshBlock.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TriangleMeshBlock.java new file mode 100644 index 0000000..6084ee3 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TriangleMeshBlock.java @@ -0,0 +1,471 @@ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon; + +import eu.svjatoslav.aukio.e3d.geometry.Box; +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.HiZPyramid; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.gui.StereoEye; +import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture; +import eu.svjatoslav.aukio.e3d.math.TransformStack; + +/** + * A block of textured triangles stored as flat primitive arrays + * (struct-of-arrays) instead of one object graph per triangle. Built once + * (off the render thread), then every frame a single tight loop applies + * the composed camera transform to all vertices — sequential memory + * access instead of pointer chasing through {@code Vertex}/{@code Point3D} + * soup, which is what made the transform phase memory-latency-bound. + * + *

Interaction with the rest of the pipeline is unchanged: each visible + * triangle is queued as a thin preallocated {@link MeshTriangle} handle + * into the same {@link RenderAggregator}, sorted by the same comparator, + * binned into the same tile grid, and painted by the same + * {@link TexturedTriangle#paintFlat} core — bit-exact with the + * object-backed path.

+ * + *

Subpixel-cull verdicts are cached per triangle ({@link #cullEpoch}): + * while the verdict epoch holds, a culled triangle costs one integer + * compare per frame — no vertex math at all.

+ * + *

Limitations vs object-backed triangles: no mouse picking, no SDF + * textures (rejected at build), no GI/lightmap integration.

+ */ +public final class TriangleMeshBlock extends AbstractShape { + + // Build-time geometry: 9 world doubles and 6 UV doubles per triangle. + private final double[] world; + private final double[] uv; + private final Texture[] textures; + private final boolean backfaceCull; + private final int triCount; + private final MeshTriangle[] handles; + private final Box boundingBox; + + // Per-slot projected state: 3 screen/camera doubles per triangle per + // slot. (Sort Z and binning bounds are published into the handles' + // per-slot fields at transform time, not kept here.) + private final double[][] projX = new double[3][]; + private final double[][] projY = new double[3][]; + private final double[][] camZ = new double[3][]; + + // Subpixel-cull verdicts: epoch in which the triangle was found tiny. + private final int[] cullEpoch; + + // Near-plane clip output per slot: packed (offset << 3) | count into + // clipStore, -1 = not clipped. Clip data is 7 doubles per loop vertex + // (camera x, y, z, u, v + screen x, y), at most 4 vertices per + // straddler. Sort Z and binning bounds for straddlers are derived + // from the store, exactly like the object path derives them from the + // clipped loop. clipTtd keeps the ORIGINAL triangle's UV perimeter + // per straddler (index = clip offset / 28): mipmap selection must + // use the unclipped texel density, exactly like the object path. + private static final int CLIP_STRIDE = 7; + private static final int CLIP_ENTRY = 4 * CLIP_STRIDE; + private final int[][] clipRef = new int[3][]; + private final double[][] clipStore = new double[3][]; + private final double[][] clipTtd = new double[3][]; + private final int[] clipUsed = new int[3]; + + // Scratch for the composed top transform of the current transform call. + private final double[] top = new double[12]; + + /** + * Builds a block from baked world-space triangle soup. + * + * @param world 9 doubles per triangle (x0,y0,z0,x1,...), world + * space; the array is adopted, not copied + * @param uv 6 doubles per triangle (u0,v0,...) in primary + * texture pixels; adopted + * @param textures one texture per triangle + * @param backfaceCull cull clockwise triangles on screen + */ + public TriangleMeshBlock(final double[] world, final double[] uv, + final Texture[] textures, + final boolean backfaceCull) { + this.triCount = textures.length; + if (world.length != triCount * 9 || uv.length != triCount * 6) + throw new IllegalArgumentException("array length mismatch"); + this.world = world; + this.uv = uv; + this.textures = textures; + this.backfaceCull = backfaceCull; + for (final Texture texture : textures) + if (texture != null && texture.isSdf()) + throw new IllegalArgumentException( + "SDF textures are not supported in mesh blocks"); + + this.handles = new MeshTriangle[triCount]; + for (int t = 0; t < triCount; t++) + handles[t] = new MeshTriangle(this, t, textures[t]); + + this.cullEpoch = new int[triCount]; + java.util.Arrays.fill(cullEpoch, -1); + + for (int s = 0; s < 3; s++) { + projX[s] = new double[triCount * 3]; + projY[s] = new double[triCount * 3]; + camZ[s] = new double[triCount * 3]; + clipRef[s] = new int[triCount]; + java.util.Arrays.fill(clipRef[s], -1); + clipStore[s] = new double[256]; + clipTtd[s] = new double[16]; + } + + double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE, + minZ = Double.MAX_VALUE; + double maxX = -Double.MAX_VALUE, maxY = -Double.MAX_VALUE, + maxZ = -Double.MAX_VALUE; + for (int i = 0; i < world.length; i += 3) { + if (world[i] < minX) minX = world[i]; + if (world[i] > maxX) maxX = world[i]; + if (world[i + 1] < minY) minY = world[i + 1]; + if (world[i + 1] > maxY) maxY = world[i + 1]; + if (world[i + 2] < minZ) minZ = world[i + 2]; + if (world[i + 2] > maxZ) maxZ = world[i + 2]; + } + boundingBox = new Box(new Point3D(minX, minY, minZ), + new Point3D(maxX, maxY, maxZ)); + } + + public int triCount() { + return triCount; + } + + @Override + public Box getBoundingBox() { + return boundingBox; + } + + @Override + public int getTransformWeight(final RenderingContext renderingContext) { + return Math.max(1, triCount); + } + + /** + * Transforms every triangle of the block with the composed top + * transform of the stack (hoisted out of the loop), culls (near + * plane, subpixel with verdict cache, viewport) and queues a thin + * handle per surviving triangle. All expressions replicate + * {@code TransformStack.transform} / + * {@code Vertex.calculateLocationRelativeToViewer} exactly, so output + * is bit-identical with the object-backed path. + */ + @Override + public void transform(final TransformStack transforms, + final RenderAggregator aggregator, + final RenderingContext renderingContext) { + final int slot = renderingContext.vertexSlot; + final double[] px = projX[slot]; + final double[] py = projY[slot]; + final double[] cz = camZ[slot]; + final int[] cref = clipRef[slot]; + clipUsed[slot] = 0; + + transforms.getTopTransform(top); + final double r0 = top[0], r1 = top[1], r2 = top[2]; + final double r3 = top[3], r4 = top[4], r5 = top[5]; + final double r6 = top[6], r7 = top[7], r8 = top[8]; + final double t0 = top[9], t1 = top[10], t2 = top[11]; + + final double near = renderingContext.nearPlaneDistance; + final double scale = renderingContext.projectionScale; + final double centerX = renderingContext.centerCoordinate.x; + final double centerY = renderingContext.centerCoordinate.y; + final double stereo = renderingContext.stereoViewportOffsetX; + + // Hi-Z whole-block occlusion: test the world AABB against last + // frame's depth pyramid before touching a single triangle. + // Skipped in stereo (the pyramid is mono-view) and whenever a + // corner crosses the near plane (its projection is unreliable). + final HiZPyramid hiz = renderingContext.occlusionPyramid; + if (hiz != null + && renderingContext.stereoEye == StereoEye.NONE) { + hiz.blocksTested.incrementAndGet(); + final Point3D lo = boundingBox.p1, hi = boundingBox.p2; + double ax1 = Double.MAX_VALUE, ay1 = Double.MAX_VALUE; + double ax2 = -Double.MAX_VALUE, ay2 = -Double.MAX_VALUE; + double nearestW = -Double.MAX_VALUE; + boolean usable = true; + for (int c = 0; c < 8; c++) { + final double wx = (c & 1) != 0 ? hi.x : lo.x; + final double wy = (c & 2) != 0 ? hi.y : lo.y; + final double wz = (c & 4) != 0 ? hi.z : lo.z; + final double ccz = r6 * wx + r7 * wy + r8 * wz + t2; + if (ccz <= near) { + usable = false; + break; + } + final double ccx = r0 * wx + r1 * wy + r2 * wz + t0; + final double ccy = r3 * wx + r4 * wy + r5 * wz + t1; + final double sx = ((ccx / ccz) * scale) + centerX + stereo; + final double sy = ((ccy / ccz) * scale) + centerY; + if (sx < ax1) ax1 = sx; + if (sx > ax2) ax2 = sx; + if (sy < ay1) ay1 = sy; + if (sy > ay2) ay2 = sy; + final double w = 1d / ccz; + if (w > nearestW) nearestW = w; + } + if (usable && ax1 <= ax2 && ay1 <= ay2 + && hiz.occluded(ax1, ay1, ax2, ay2, nearestW)) { + hiz.blocksCulled.incrementAndGet(); + return; + } + } + final double cullThreshold = renderingContext.subpixelCullingThreshold; + final int epoch = renderingContext.subpixelCullingEpoch; + final double rMinX = renderingContext.renderMinX; + final double rMaxX = renderingContext.renderMaxX; + final double rMinY = renderingContext.renderMinY; + final double rMaxY = renderingContext.renderMaxY; + + for (int t = 0; t < triCount; t++) { + if (cullThreshold > 0 && cullEpoch[t] == epoch) + continue; + + final int w = t * 9; + // Same expression order as TransformStack.transform. + final double x0 = world[w], y0 = world[w + 1], z0 = world[w + 2]; + final double cx0 = r0 * x0 + r1 * y0 + r2 * z0 + t0; + final double cy0 = r3 * x0 + r4 * y0 + r5 * z0 + t1; + final double cz0 = r6 * x0 + r7 * y0 + r8 * z0 + t2; + final double x1 = world[w + 3], y1 = world[w + 4], z1 = world[w + 5]; + final double cx1 = r0 * x1 + r1 * y1 + r2 * z1 + t0; + final double cy1 = r3 * x1 + r4 * y1 + r5 * z1 + t1; + final double cz1 = r6 * x1 + r7 * y1 + r8 * z1 + t2; + final double x2 = world[w + 6], y2 = world[w + 7], z2 = world[w + 8]; + final double cx2 = r0 * x2 + r1 * y2 + r2 * z2 + t0; + final double cy2 = r3 * x2 + r4 * y2 + r5 * z2 + t1; + final double cz2 = r6 * x2 + r7 * y2 + r8 * z2 + t2; + + final boolean in0 = cz0 > near; + final boolean in1 = cz1 > near; + final boolean in2 = cz2 > near; + + if (!in0 && !in1 && !in2) { + cref[t] = -1; + continue; + } + + final int v = t * 3; + if (!(in0 && in1 && in2)) { + clipAndQueue(t, v, slot, cx0, cy0, cz0, cx1, cy1, cz1, + cx2, cy2, cz2, in0, in1, in2, near, cref, + aggregator, renderingContext); + continue; + } + + cref[t] = -1; + cz[v] = cz0; + cz[v + 1] = cz1; + cz[v + 2] = cz2; + // Same expression order as + // Vertex.calculateLocationRelativeToViewer (divide, scale, + // add center, add stereo offset). + final double sx0 = ((cx0 / cz0) * scale) + centerX + stereo; + final double sy0 = ((cy0 / cz0) * scale) + centerY; + final double sx1 = ((cx1 / cz1) * scale) + centerX + stereo; + final double sy1 = ((cy1 / cz1) * scale) + centerY; + final double sx2 = ((cx2 / cz2) * scale) + centerX + stereo; + final double sy2 = ((cy2 / cz2) * scale) + centerY; + px[v] = sx0; + py[v] = sy0; + px[v + 1] = sx1; + py[v + 1] = sy1; + px[v + 2] = sx2; + py[v + 2] = sy2; + final double triZ = (cz0 + cz1 + cz2) / 3; + + final double minX = Math.min(sx0, Math.min(sx1, sx2)); + final double maxX = Math.max(sx0, Math.max(sx1, sx2)); + final double minY = Math.min(sy0, Math.min(sy1, sy2)); + final double maxY = Math.max(sy0, Math.max(sy1, sy2)); + + // Publish the same per-slot state an object-backed triangle + // would have written (paint margins are 0 for mesh tris): + // comparator and tile binning then read plain fields. + handles[t].publishSlotState(slot, triZ, minY, maxY, minX, maxX); + + // Subpixel verdict with per-triangle cache (same raw-span + // semantics as AbstractCoordinateShape). + if (cullThreshold > 0 + && maxX - minX < cullThreshold + && maxY - minY < cullThreshold) { + cullEpoch[t] = epoch; + continue; + } + + // Viewport cull (paint margins are 0 for mesh triangles). + if (maxX < rMinX || minX >= rMaxX || maxY < rMinY || minY >= rMaxY) + continue; + + aggregator.queueShapeForRendering(handles[t]); + } + } + + /** + * Near-plane clip for one straddling triangle, Sutherland-Hodgman + * over the three edges with the exact interpolation expressions of + * {@code AbstractCoordinateShape.interpolateAtPlane}. Output goes to + * the slot's grow-only clip store; the handle is queued with a packed + * reference. + */ + private void clipAndQueue(final int t, final int v, final int slot, + final double cx0, final double cy0, final double cz0, + final double cx1, final double cy1, final double cz1, + final double cx2, final double cy2, final double cz2, + final boolean in0, final boolean in1, final boolean in2, + final double near, final int[] cref, + final RenderAggregator aggregator, + final RenderingContext renderingContext) { + double[] store = clipStore[slot]; + int used = clipUsed[slot]; + if (used + CLIP_ENTRY > store.length) { + final double[] grown = new double[store.length * 2]; + System.arraycopy(store, 0, grown, 0, used); + store = grown; + clipStore[slot] = grown; + final double[] grownTtd = new double[grown.length / CLIP_ENTRY]; + System.arraycopy(clipTtd[slot], 0, grownTtd, 0, clipTtd[slot].length); + clipTtd[slot] = grownTtd; + } + final int base = used; + clipTtd[slot][base / CLIP_ENTRY] = origTtd(t); + + final double scale = renderingContext.projectionScale; + final double centerX = renderingContext.centerCoordinate.x; + final double centerY = renderingContext.centerCoordinate.y; + final double stereo = renderingContext.stereoViewportOffsetX; + + final double[] cx = {cx0, cx1, cx2}; + final double[] cy = {cy0, cy1, cy2}; + final double[] czz = {cz0, cz1, cz2}; + final boolean[] in = {in0, in1, in2}; + final int uvi = t * 6; + + int n = 0; + double sumZ = 0; + for (int i = 0; i < 3; i++) { + final int j = (i + 1) % 3; + final boolean currentIn = in[i]; + final boolean nextIn = in[j]; + if (currentIn) { + store[used++] = cx[i]; + store[used++] = cy[i]; + store[used++] = czz[i]; + store[used++] = uv[uvi + i * 2]; + store[used++] = uv[uvi + i * 2 + 1]; + // setCameraSpaceCoordinate expression, same order + store[used++] = ((cx[i] / czz[i]) * scale) + centerX + stereo; + store[used++] = ((cy[i] / czz[i]) * scale) + centerY; + n++; + sumZ += czz[i]; + } + if (currentIn != nextIn) { + final double tt = (near - czz[i]) / (czz[j] - czz[i]); + final double ix = cx[i] + (cx[j] - cx[i]) * tt; + final double iy = cy[i] + (cy[j] - cy[i]) * tt; + final double iz = czz[i] + (czz[j] - czz[i]) * tt; + store[used++] = ix; + store[used++] = iy; + store[used++] = iz; + store[used++] = uv[uvi + i * 2] + + (uv[uvi + j * 2] - uv[uvi + i * 2]) * tt; + store[used++] = uv[uvi + i * 2 + 1] + + (uv[uvi + j * 2 + 1] - uv[uvi + i * 2 + 1]) * tt; + store[used++] = ((ix / iz) * scale) + centerX + stereo; + store[used++] = ((iy / iz) * scale) + centerY; + n++; + sumZ += iz; + } + } + + // Degenerate sliver: fewer loop points than a renderable triangle. + if (n < 3) { + cref[t] = -1; + return; + } + clipUsed[slot] = used; + cref[t] = (base << 3) | n; + // Object path averages Z and derives bounds over the clipped loop + double cMinX = Double.MAX_VALUE, cMaxX = -Double.MAX_VALUE; + double cMinY = Double.MAX_VALUE, cMaxY = -Double.MAX_VALUE; + for (int i = 0; i < n; i++) { + final double sx = store[base + i * CLIP_STRIDE + 5]; + final double sy = store[base + i * CLIP_STRIDE + 6]; + if (sx < cMinX) cMinX = sx; + if (sx > cMaxX) cMaxX = sx; + if (sy < cMinY) cMinY = sy; + if (sy > cMaxY) cMaxY = sy; + } + handles[t].publishSlotState(slot, sumZ / n, cMinY, cMaxY, cMinX, cMaxX); + aggregator.queueShapeForRendering(handles[t]); + } + + // ---- handle-facing accessors (package-private) ---- + + double camZ(final int slot, final int tri, final int vertex) { + return camZ[slot][tri * 3 + vertex]; + } + + Texture texture(final int tri) { + return textures[tri]; + } + + boolean backfaceCull() { + return backfaceCull; + } + + int clipOffset(final int slot, final int tri) { + final int ref = clipRef[slot][tri]; + return ref < 0 ? -1 : ref >> 3; + } + + int clipCount(final int slot, final int tri) { + return clipRef[slot][tri] & 7; + } + + double[] clipStore(final int slot) { + return clipStore[slot]; + } + + double clipTtd(final int slot, final int clipOffset) { + return clipTtd[slot][clipOffset / CLIP_ENTRY]; + } + + /** + * The triangle's UV perimeter, with the exact expression and + * accumulation order of {@code TexturedTriangle}'s + * computeTotalTextureDistance: d(0,1) + d(0,2) + d(1,2). + */ + double origTtd(final int tri) { + final int u = tri * 6; + final double d1 = Math.sqrt( + ((uv[u] - uv[u + 2]) * (uv[u] - uv[u + 2])) + + ((uv[u + 1] - uv[u + 3]) * (uv[u + 1] - uv[u + 3]))); + final double d2 = Math.sqrt( + ((uv[u] - uv[u + 4]) * (uv[u] - uv[u + 4])) + + ((uv[u + 1] - uv[u + 5]) * (uv[u + 1] - uv[u + 5]))); + final double d3 = Math.sqrt( + ((uv[u + 2] - uv[u + 4]) * (uv[u + 2] - uv[u + 4])) + + ((uv[u + 3] - uv[u + 5]) * (uv[u + 3] - uv[u + 5]))); + return d1 + d2 + d3; + } + + /** + * Loads one unclipped triangle vertex (screen + UV) into the scratch + * carriers, values exactly as computed at transform time. + */ + void loadScreenVertex(final Point2D screen, final Point2D uvOut, + final int slot, final int tri, final int vertex, + final RenderingContext ctx) { + final int v = tri * 3 + vertex; + screen.x = projX[slot][v]; + screen.y = projY[slot][v]; + uvOut.x = uv[tri * 6 + vertex * 2]; + uvOut.y = uv[tri * 6 + vertex * 2 + 1]; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/package-info.java new file mode 100644 index 0000000..3b138bb --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/package-info.java @@ -0,0 +1,28 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Textured triangle rendering with perspective-correct UV mapping. + * + *

Textured triangles apply 2D textures to 3D triangles using UV coordinates. + * Quake-style subdivided perspective correction (per-scanline recovery of exact + * u/v from linearly interpolated u/z, v/z, 1/z) keeps textures correct at any + * triangle size, so no tessellation is needed.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle} - + * The base textured triangle with perspective-correct scanline rendering
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.PerspectiveBorderInterpolator} - + * Edge interpolation of u/z, v/z, 1/z gradients
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.PolygonBorderInterpolator} - + * Affine edge interpolation (fallback path)
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle + * @see eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture + */ + +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon; diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/ForwardOrientedTextBlock.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/ForwardOrientedTextBlock.java new file mode 100644 index 0000000..a0d8468 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/ForwardOrientedTextBlock.java @@ -0,0 +1,108 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.Billboard; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture; + +import java.awt.Font; + +/** + * A text label rendered as a billboard texture that always faces the camera. + * + *

This shape renders a single line of text onto a {@link Texture} using the cell metrics + * defined in {@link TextCanvas} ({@link TextCanvas#FONT_CHAR_WIDTH_TEXTURE_PIXELS}, + * {@link TextCanvas#FONT_CHAR_HEIGHT_TEXTURE_PIXELS}), then displays the texture as a + * forward-oriented billboard via its {@link Billboard} superclass. The result + * is a text label that remains readable from any viewing angle.

+ * + *

Usage example:

+ *
{@code
+ * // Create a red text label at position (0, -50, 300)
+ * ForwardOrientedTextBlock label = new ForwardOrientedTextBlock(
+ *     new Point3D(0, -50, 300),
+ *     1.0,
+ *     2,
+ *     "Hello, World!",
+ *     Color.RED
+ * );
+ * shapeCollection.addShape(label);
+ * }
+ * + * @see Billboard + * @see TextCanvas + * @see Texture + */ +public class ForwardOrientedTextBlock extends Billboard { + + /** + * The font used to render the label text. Matches the SDF text + * pipeline's family (Liberation Mono Bold, Courier-metric-compatible) + * at the cell size used by {@link TextCanvas}. + */ + private static final Font FONT = createFont(); + + private static Font createFont() { + final Font font = new Font("Liberation Mono", Font.BOLD, 30); + if (!font.getFamily().toLowerCase().contains("liberation")) { + return new Font("Monospaced", Font.BOLD, 30); + } + return font; + } + + /** + * Creates a new forward-oriented text block at the given 3D position. + * + * @param point the 3D position where the text label is placed + * @param scale the scale factor controlling the rendered size of the text + * @param maxUpscaleFactor the maximum mipmap upscale factor for the backing texture + * @param text the text string to render + * @param textColor the color of the rendered text + */ + public ForwardOrientedTextBlock(final Point3D point, final double scale, + final int maxUpscaleFactor, final String text, + final eu.svjatoslav.aukio.e3d.renderer.raster.Color textColor) { + super(point, scale, getTexture(text, maxUpscaleFactor, textColor)); + + } + + /** + * Creates a {@link Texture} containing the rendered text string. + * + *

The texture dimensions are calculated from the text length and the cell metrics + * defined in {@link TextCanvas}. Each character is drawn individually at the appropriate + * horizontal offset.

+ * + * @param text the text string to render into the texture + * @param maxUpscaleFactor the maximum mipmap upscale factor for the texture + * @param textColor the color of the rendered text + * @return a new {@link Texture} containing the rendered text + */ + public static Texture getTexture(final String text, + final int maxUpscaleFactor, + final eu.svjatoslav.aukio.e3d.renderer.raster.Color textColor) { + + final Texture texture = new Texture(text.length() + * TextCanvas.FONT_CHAR_WIDTH_TEXTURE_PIXELS, TextCanvas.FONT_CHAR_HEIGHT_TEXTURE_PIXELS, + maxUpscaleFactor); + + // Put blue background to test if texture has correct size + // texture.graphics.setColor(Color.BLUE); + // texture.graphics.fillRect(0, 0, texture.primaryBitmap.width, + // texture.primaryBitmap.width); + + texture.graphics.setFont(FONT); + texture.graphics.setColor(textColor.toAwtColor()); + + for (int c = 0; c < text.length(); c++) + texture.graphics.drawChars(new char[]{text.charAt(c),}, 0, 1, + (c * TextCanvas.FONT_CHAR_WIDTH_TEXTURE_PIXELS), + (int) (TextCanvas.FONT_CHAR_HEIGHT_TEXTURE_PIXELS / 1.45)); + + return texture; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/Graph.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/Graph.java new file mode 100644 index 0000000..a54809b --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/Graph.java @@ -0,0 +1,180 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.math.Transform; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas; + +import java.util.List; + +/** + * A 2D graph visualization rendered in 3D space. + * + *

Plots a series of {@link Point2D} data points as a connected line graph, overlaid on a + * grid with horizontal and vertical grid lines, axis labels, and a title. The graph is + * rendered in the XY plane at the specified 3D location, with all dimensions scaled by + * a configurable scale factor.

+ * + *

The graph uses the following default configuration:

+ *
    + *
  • X-axis range: {@code 0} to {@code 20} (world units before scaling)
  • + *
  • Y-axis range: {@code -2} to {@code 2}
  • + *
  • Grid spacing: {@code 0.5} in both horizontal and vertical directions
  • + *
  • Grid color: semi-transparent blue ({@code rgba(100, 100, 250, 100)})
  • + *
  • Plot color: semi-transparent red ({@code rgba(255, 0, 0, 100)})
  • + *
+ * + *

Usage example:

+ *
{@code
+ * // Prepare data points
+ * List data = new ArrayList<>();
+ * for (double x = 0; x <= 20; x += 0.1) {
+ *     data.add(new Point2D(x, Math.sin(x)));
+ * }
+ *
+ * // Create a graph at position (0, 0, 500) with scale factor 10
+ * Graph graph = new Graph(10.0, data, "sin(x)", new Point3D(0, 0, 500));
+ *
+ * // Add to the scene
+ * shapeCollection.addShape(graph);
+ * }
+ * + * @see Line + * @see TextCanvas + * @see AbstractCompositeShape + */ +public class Graph extends AbstractCompositeShape { + + /** The width of the graph in unscaled world units. */ + private final double width; + /** The minimum Y-axis value. */ + private final double yMin; + /** The maximum Y-axis value. */ + private final double yMax; + /** The spacing between vertical grid lines along the X-axis. */ + private final double horizontalStep; + /** The spacing between horizontal grid lines along the Y-axis. */ + private final double verticalStep; + /** The color used for grid lines. */ + private final Color gridColor; + /** The width of grid lines in world units (after scaling). */ + private final double lineWidth; + /** The color used for the data plot line. */ + private final Color plotColor; + + /** + * Creates a new graph visualization at the specified 3D location. + * + *

The graph is constructed with grid lines, axis labels, plotted data, and a title + * label. All spatial dimensions are multiplied by the given scale factor.

+ * + * @param scale the scale factor applied to all spatial dimensions of the graph + * @param data the list of 2D data points to plot; consecutive points are connected by lines + * @param label the title text displayed above the graph + * @param location the 3D position of the graph's origin in the scene + */ + public Graph(final double scale, final List data, + final String label, final Point3D location) { + super(location); + + width = 20; + + yMin = -2; + yMax = 2; + + horizontalStep = 0.5; + verticalStep = 0.5; + + gridColor = new Color(100, 100, 250, 100); + + lineWidth = 0.1 * scale; + plotColor = new Color(255, 0, 0, 100); + + addVerticalLines(scale); + addXLabels(scale); + addHorizontalLinesAndLabels(scale); + plotData(scale, data); + + final Point3D labelLocation = new Point3D(width / 2, yMax + 0.5, 0) + .multiply(scale); + + final TextCanvas labelCanvas = new TextCanvas(new Transform( + labelLocation), label, Color.WHITE, Color.TRANSPARENT); + + addShape(labelCanvas); + } + + private void addHorizontalLinesAndLabels(final double scale) { + for (double y = yMin; y <= yMax; y += verticalStep) { + + final Point3D p1 = new Point3D(0, y, 0).multiply(scale); + + final Point3D p2 = new Point3D(width, y, 0).multiply(scale); + + final Line line = new Line(p1, p2, gridColor, lineWidth); + + addShape(line); + + final Point3D labelLocation = new Point3D(-0.5, y, 0) + .multiply(scale); + + final TextCanvas label = new TextCanvas( + new Transform(labelLocation), String.valueOf(y), + Color.WHITE, Color.TRANSPARENT); + + addShape(label); + + } + } + + private void addVerticalLines(final double scale) { + for (double x = 0; x <= width; x += horizontalStep) { + + final Point3D p1 = new Point3D(x, yMin, 0).multiply(scale); + final Point3D p2 = new Point3D(x, yMax, 0).multiply(scale); + + final Line line = new Line(p1, p2, gridColor, lineWidth); + + addShape(line); + + } + } + + private void addXLabels(final double scale) { + for (double x = 0; x <= width; x += horizontalStep * 2) { + final Point3D labelLocation = new Point3D(x, yMin - 0.4, 0) + .multiply(scale); + + final TextCanvas label = new TextCanvas( + new Transform(labelLocation), String.valueOf(x), + Color.WHITE, Color.TRANSPARENT); + + addShape(label); + } + } + + private void plotData(final double scale, final List data) { + Point3D previousPoint = null; + for (final Point2D point : data) { + + final Point3D p3d = new Point3D(point.x, point.y, 0).multiply(scale); + + if (previousPoint != null) { + + final Line line = new Line(previousPoint, p3d, plotColor, + 0.4 * scale); + + addShape(line); + } + + previousPoint = p3d; + } + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightSourceMarker.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightSourceMarker.java new file mode 100755 index 0000000..0ef6094 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightSourceMarker.java @@ -0,0 +1,43 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.GlowingPoint; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A visual marker that indicates a light source position in the 3D scene. + * + *

Rendered as a glowing point that provides a clear, lightweight visual + * indicator useful for debugging light placement in the scene.

+ * + *

Usage example:

+ *
{@code
+ * // Place a yellow light source marker at position (100, -50, 200)
+ * LightSourceMarker marker = new LightSourceMarker(
+ *     new Point3D(100, -50, 200),
+ *     Color.YELLOW
+ * );
+ * shapeCollection.addShape(marker);
+ * }
+ * + * @see GlowingPoint + * @see AbstractCompositeShape + */ +public class LightSourceMarker extends AbstractCompositeShape { + + /** + * Creates a new light source marker at the specified location. + * + * @param location the 3D position of the marker in the scene + * @param color the color of the glowing point + */ + public LightSourceMarker(final Point3D location, final Color color) { + super(location); + addShape(new GlowingPoint(new Point3D(0, 0, 0), 15, color)); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.java new file mode 100644 index 0000000..9aba6d4 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.java @@ -0,0 +1,176 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.gi.LightmappedTriangle; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +import java.util.ArrayList; +import java.util.List; + +/** + * Composite shape whose solid polygons can render as lightmapped + * triangles for the global illumination system. + * + *

Lightmapping: when enabled, every {@link SolidPolygon} + * (including those inside nested composites, which are flattened) is + * wrapped as a {@link LightmappedTriangle} with its own lightmap texture, + * and the GI system paints light across its surface instead of leaving + * it a single flat color. Rebuild is automatic via the normal + * cache-invalidation path.

+ * + *

Ordering: none required — the engine's z-buffer resolves + * visibility per pixel, so this class needs no spatial ordering + * structure of its own. Nested composites are flattened assuming + * identity transforms (vertices are used in the composite's local + * space); animate transforms above this composite, not inside it.

+ * + * @see LightmappedTriangle the per-fragment lightmap carrier + * @see AbstractCompositeShape the base composite (fan-triangulates + * N-vertex polygons when building the render list) + */ +public class LightmappedCompositeShape extends AbstractCompositeShape { + + /** + * When true, the composite's polygons render as + * {@link LightmappedTriangle}s: each polygon gets its own lightmap + * texture and the GI system paints light across its surface. + */ + private boolean lightmappingEnabled; + + /** World units per lightmap texel (smaller = finer GI detail). */ + private double lightmapUnitsPerTexel = 12.0; + + /** + * Replaces solid polygons with lightmapped wrappers when lightmapping + * is enabled; otherwise returns the list unchanged. + * + * @param renderList the render list built from the shape registry + * @return lightmapped wrappers + non-polygon passthrough shapes + */ + @Override + protected List postprocessRenderList(final List renderList) { + if (!lightmappingEnabled) + return renderList; + + final List result = new ArrayList<>(renderList.size()); + for (final AbstractShape shape : renderList) { + if (shape instanceof SolidPolygon) { + wrapLightmapped((SolidPolygon) shape, result); + } else if (shape instanceof AbstractCompositeShape) { + // Flatten nested composites. Assumes identity transforms + // on the nested composite chain. + for (final SolidPolygon polygon + : ((AbstractCompositeShape) shape).extractSolidPolygons()) + wrapLightmapped(polygon, result); + } else { + result.add(shape); + } + } + return result; + } + + /** + * Enables or disables lightmapping. When enabled, the composite's + * polygons render as {@link LightmappedTriangle}s with per-polygon + * lightmap textures filled by the global illumination system. + * Rebuilds the render list on the next frame. + * + * @param enabled true to render polygons lightmapped + */ + public void setLightmappingEnabled(final boolean enabled) { + if (lightmappingEnabled != enabled) { + lightmappingEnabled = enabled; + setCacheNeedsRebuild(true); + } + } + + /** + * Returns whether lightmapping is enabled. + * + * @return true when polygons render as lightmapped triangles + */ + public boolean isLightmappingEnabled() { + return lightmappingEnabled; + } + + /** + * Sets the lightmap resolution. Default 12 world units per texel: + * a 100-unit wall cell gets an 8x8 lightmap. Halving the units + * quadruples the GI tracing work; finer texels are the ONLY way to + * smoother shadow edges (there is no upsampling). Takes effect on the + * next render list rebuild. + * + * @param unitsPerTexel world units per texel + */ + public void setLightmapUnitsPerTexel(final double unitsPerTexel) { + lightmapUnitsPerTexel = unitsPerTexel; + setCacheNeedsRebuild(true); + } + + /** + * Fan-triangulates a polygon (triangles pass through as-is) and wraps + * each triangle into a lightmapped textured triangle with the same + * geometry, color and culling. The initial texture is dim; the GI + * system converges it to full lighting within seconds. + * + * @param polygon the polygon to wrap (any vertex count >= 3) + * @param out the list receiving the lightmapped triangles + */ + private void wrapLightmapped(final SolidPolygon polygon, final List out) { + final int vertexCount = polygon.getVertexCount(); + if (vertexCount == 3) { + out.add(wrapTriangle(polygon)); + return; + } + // Fan: anchor vertex 0, then consecutive pairs + final Point3D anchor = polygon.vertices.get(0).coordinate; + for (int i = 1; i + 1 < vertexCount; i++) { + final SolidPolygon triangle = new SolidPolygon( + anchor, + polygon.vertices.get(i).coordinate, + polygon.vertices.get(i + 1).coordinate, + polygon.getColor()); + triangle.setShadingEnabled(polygon.isShadingEnabled()); + triangle.setBackfaceCulling(polygon.isBackfaceCullingEnabled()); + triangle.setMouseInteractionController(polygon.mouseInteractionController); + out.add(wrapTriangle(triangle)); + } + } + + /** + * Wraps one triangle into a lightmapped textured triangle with the + * same geometry, color and culling. + */ + private AbstractCoordinateShape wrapTriangle(final SolidPolygon polygon) { + final Point3D a = polygon.vertices.get(0).coordinate; + final Point3D b = polygon.vertices.get(1).coordinate; + final Point3D c = polygon.vertices.get(2).coordinate; + + // Normal: right-handed cross(b-a, c-a). + final double e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z; + final double e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z; + double nx = e1y * e2z - e1z * e2y; + double ny = e1z * e2x - e1x * e2z; + double nz = e1x * e2y - e1y * e2x; + final double len = Math.sqrt(nx * nx + ny * ny + nz * nz); + if (len > 0) { + nx /= len; + ny /= len; + nz /= len; + } + + final LightmappedTriangle triangle = new LightmappedTriangle( + a, b, c, polygon.getColor(), lightmapUnitsPerTexel, + nx, ny, nz); + triangle.setBackfaceCulling(polygon.isBackfaceCullingEnabled()); + triangle.setMouseInteractionController(polygon.mouseInteractionController); + return triangle; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/TexturedRectangle.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/TexturedRectangle.java new file mode 100644 index 0000000..89e8d79 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/TexturedRectangle.java @@ -0,0 +1,180 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.math.Transform; +import eu.svjatoslav.aukio.e3d.math.Vertex; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture; + +/** + * A rectangular shape with texture mapping, composed of two textured triangles. + * + *

This composite shape creates a textured rectangle in 3D space by splitting it into + * two {@link TexturedTriangle} triangles that share a common {@link Texture}. The rectangle + * is centered at the origin of its local coordinate system, with configurable world-space + * dimensions and independent texture resolution.

+ * + *

The contained {@link Texture} object is accessible via {@link #getTexture()}, allowing + * dynamic rendering to the texture surface (e.g., drawing text, images, or procedural content) + * after construction.

+ * + *

Usage example:

+ *
{@code
+ * // Create a 200x100 textured rectangle at position (0, 0, 300)
+ * Transform transform = new Transform(new Point3D(0, 0, 300));
+ * TexturedRectangle rect = new TexturedRectangle(transform, 200, 100, 2);
+ *
+ * // Draw onto the texture dynamically
+ * Texture tex = rect.getTexture();
+ * tex.graphics.setColor(java.awt.Color.RED);
+ * tex.graphics.fillRect(0, 0, 50, 50);
+ *
+ * // Add to the scene
+ * shapeCollection.addShape(rect);
+ * }
+ * + * @see TexturedTriangle + * @see Texture + * @see AbstractCompositeShape + */ +public class TexturedRectangle extends AbstractCompositeShape { + + /** Top-left corner position in local 3D coordinates. */ + public Point3D topLeft; + /** Top-right corner position in local 3D coordinates. */ + public Point3D topRight; + /** Bottom-right corner position in local 3D coordinates. */ + public Point3D bottomRight; + /** Bottom-left corner position in local 3D coordinates. */ + public Point3D bottomLeft; + /** Top-left corner mapping in texture coordinates (pixels). */ + public Point2D textureTopLeft; + /** Top-right corner mapping in texture coordinates (pixels). */ + public Point2D textureTopRight; + /** Bottom-right corner mapping in texture coordinates (pixels). */ + public Point2D textureBottomRight; + /** Bottom-left corner mapping in texture coordinates (pixels). */ + public Point2D textureBottomLeft; + private Texture texture; + + /** + * Creates a textured rectangle with only a transform, without initializing geometry. + * + *

After construction, call {@link #initialize(double, double, int, int, int)} to + * set up the rectangle's dimensions, texture, and triangle geometry.

+ * + * @param transform the position and orientation of this rectangle in the scene + */ + public TexturedRectangle(final Transform transform) { + super(transform); + } + + /** + * Creates a textured rectangle where the texture resolution matches the world-space size. + * + *

This is a convenience constructor equivalent to calling + * {@link #TexturedRectangle(Transform, int, int, int, int, int)} with + * {@code textureWidth = width} and {@code textureHeight = height}.

+ * + * @param transform the position and orientation of this rectangle in the scene + * @param width the width of the rectangle in world units (also used as texture width in pixels) + * @param height the height of the rectangle in world units (also used as texture height in pixels) + * @param maxTextureUpscale the maximum mipmap upscale factor for the texture + */ + public TexturedRectangle(final Transform transform, final int width, + final int height, final int maxTextureUpscale) { + this(transform, width, height, width, height, maxTextureUpscale); + } + + /** + * Creates a fully initialized textured rectangle with independent world-space size and texture resolution. + * + * @param transform the position and orientation of this rectangle in the scene + * @param width the width of the rectangle in world units + * @param height the height of the rectangle in world units + * @param textureWidth the width of the backing texture in pixels + * @param textureHeight the height of the backing texture in pixels + * @param maxTextureUpscale the maximum mipmap upscale factor for the texture + */ + public TexturedRectangle(final Transform transform, final int width, + final int height, final int textureWidth, final int textureHeight, + final int maxTextureUpscale) { + + super(transform); + + initialize(width, height, textureWidth, textureHeight, + maxTextureUpscale); + } + + /** + * Returns the backing texture for this rectangle. + * + *

The returned {@link Texture} can be used to draw dynamic content onto the + * rectangle's surface via its {@code graphics} field (a {@link java.awt.Graphics2D} instance).

+ * + * @return the texture mapped onto this rectangle + */ + public Texture getTexture() { + return texture; + } + + /** + * Initializes the rectangle geometry, texture, and the two constituent textured triangles. + * + *

The rectangle is centered at the local origin: corners span from + * {@code (-width/2, -height/2, 0)} to {@code (width/2, height/2, 0)}. + * Two {@link TexturedTriangle} triangles are created to cover the full rectangle, + * sharing a single {@link Texture} instance.

+ * + * @param width the width of the rectangle in world units + * @param height the height of the rectangle in world units + * @param textureWidth the width of the backing texture in pixels + * @param textureHeight the height of the backing texture in pixels + * @param maxTextureUpscale the maximum mipmap upscale factor for the texture + */ + public void initialize(final double width, final double height, + final int textureWidth, final int textureHeight, + final int maxTextureUpscale) { + + topLeft = new Point3D(-width / 2, -height / 2, 0); + topRight = new Point3D(width / 2, -height / 2, 0); + bottomRight = new Point3D(width / 2, height / 2, 0); + bottomLeft = new Point3D(-width / 2, height / 2, 0); + + texture = new Texture(textureWidth, textureHeight, maxTextureUpscale); + + textureTopRight = new Point2D(textureWidth, 0); + textureTopLeft = new Point2D(0, 0); + textureBottomRight = new Point2D(textureWidth, textureHeight); + textureBottomLeft = new Point2D(0, textureHeight); + + + + + final TexturedTriangle texturedPolygon1 = new TexturedTriangle( + new Vertex(topLeft, textureTopLeft), + new Vertex(topRight, textureTopRight), + new Vertex(bottomRight, textureBottomRight), texture); + + texturedPolygon1 + .setMouseInteractionController(mouseInteractionController); + + final TexturedTriangle texturedPolygon2 = new TexturedTriangle( + new Vertex(topLeft, textureTopLeft), + new Vertex(bottomLeft, textureBottomLeft), + new Vertex(bottomRight, textureBottomRight), texture); + + texturedPolygon2 + .setMouseInteractionController(mouseInteractionController); + + addShape(texturedPolygon1); + addShape(texturedPolygon2); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java new file mode 100644 index 0000000..46724bb --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java @@ -0,0 +1,1295 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base; + +import eu.svjatoslav.aukio.e3d.geometry.Box; +import eu.svjatoslav.aukio.e3d.geometry.BspTree; +import eu.svjatoslav.aukio.e3d.geometry.Frustum; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.gui.ViewSpaceTracker; +import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController; +import eu.svjatoslav.aukio.e3d.math.Transform; +import eu.svjatoslav.aukio.e3d.math.TransformStack; +import eu.svjatoslav.aukio.e3d.math.Vertex; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.ParallelTransformCoordinator; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * A composite shape that groups multiple sub-shapes into a single logical unit. + * + *

Use {@code AbstractCompositeShape} to build complex 3D objects by combining + * primitive shapes (lines, polygons, textured polygons) into a group that can be + * positioned, rotated, and manipulated as one entity. Sub-shapes can be organized + * into named groups for selective visibility toggling.

+ * + *

Usage example - creating a custom composite shape:

+ *
{@code
+ * // Create a composite shape at position (0, 0, 200)
+ * AbstractCompositeShape myObject = new AbstractCompositeShape(
+ *     new Point3D(0, 0, 200)
+ * );
+ *
+ * // Add sub-shapes
+ * myObject.addShape(new Line(
+ *     new Point3D(-50, 0, 0), new Point3D(50, 0, 0),
+ *     Color.RED, 2.0
+ * ));
+ *
+ * // Add shapes to a named group for toggling visibility
+ * myObject.addShape(labelShape, "labels");
+ * myObject.hideGroup("labels");  // hide all shapes in "labels" group
+ * myObject.showGroup("labels");  // show them again
+ *
+ * // Add to scene
+ * viewPanel.getRootShapeCollection().addShape(myObject);
+ * }
+ * + *

Perspective-correct texturing:

+ *

Textured polygons are rendered with Quake-style perspective-correct scanline + * mapping ({@code TexturedTriangle}), so no screen-size tessellation is needed.

+ * + *

Extending this class:

+ *

Override {@link #beforeTransformHook} to customize shape appearance or behavior + * on each frame (e.g., animations, dynamic geometry updates).

+ * + * @see SubShape wrapper for individual sub-shapes with group and visibility support + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape the base shape class + */ +public class AbstractCompositeShape extends AbstractShape { + /** + * Source-of-truth registry of all sub-shapes added to this composite. + * + *

Each sub-shape is wrapped with its group identifier and visibility state. + * Shapes are stored in insertion order and remain in this collection even when + * hidden (visibility state toggles instead of removal).

+ * + *

Performance note: This list is NOT processed for every frame. + * Instead, it serves as the authoritative source from which {@link #cachedRenderList} + * is compiled whenever the cache becomes invalid (see {@link #cacheNeedsRebuild}). + * Only modifications to this registry (add/remove/show/hide) trigger cache rebuild.

+ * + * @see #cachedRenderList the frame-optimized cache derived from this registry + * @see #cacheNeedsRebuild the flag controlling when the cache is rebuilt + */ + private final List subShapesRegistry = new ArrayList<>(); + + /** + * Tracks the distance and angle between the camera and this shape. + * Used e.g. by TextCanvas for distance-based rendering mode selection. + */ + private final ViewSpaceTracker viewSpaceTracker; + + /** + * Frame-optimized cache of shapes ready for rendering, derived from {@link #subShapesRegistry}. + * + *

This list is processed during every frame in the {@link #transform} method. + * It contains:

+ *
    + *
  • Shapes passing through directly (Line, TexturedTriangle, ...)
  • + *
  • Solid polygons with more than 3 vertices - fan-triangulated
  • + *
+ * + *

Caching strategy: The list is rebuilt only when + * {@link #cacheNeedsRebuild} is true, avoiding per-frame reconstruction + * overhead.

+ * + * @see #subShapesRegistry the source registry this cache is derived from + * @see #cacheNeedsRebuild the flag that triggers cache regeneration + */ + private List cachedRenderList = new ArrayList<>(); + + /** + * Flag indicating whether {@link #cachedRenderList} needs to be rebuilt from {@link #subShapesRegistry}. + * + *

Set to {@code true} when:

+ *
    + *
  • A shape is added via {@link #addShape}
  • + *
  • A shape is removed via {@link #removeGroup}
  • + *
  • Group visibility changes via {@link #showGroup} or {@link #hideGroup}
  • + *
+ * + *

Set to {@code false} after {@link #rebuildRenderList} completes the cache rebuild.

+ * + *

This flag enables the performance optimization of avoiding per-frame list + * reconstruction - the registry is only re-processed when something actually changed.

+ * + * @see #subShapesRegistry the source data that may need reprocessing + * @see #cachedRenderList the cache that gets rebuilt when this flag is true + */ + private boolean cacheNeedsRebuild = true; + + /** + * Flag indicating this composite is the root scene container (ShapeCollection's root). + * + *

Set via {@link #setRootComposite(boolean)} by ShapeCollection.

+ */ + private boolean isRootComposite = false; + + /** + * The position and orientation transform for this composite shape. + * Applied to all sub-shapes during the rendering transform pass. + */ + private Transform transform; + + /** + * Creates a composite shape at the world origin with no rotation. + */ + public AbstractCompositeShape() { + this(new Transform()); + } + + /** + * Creates a composite shape at the specified location with no rotation. + * + * @param location the position in world space + */ + public AbstractCompositeShape(final Point3D location) { + this(new Transform(location)); + } + + /** + * Creates a composite shape with the specified transform (position and orientation). + * + * @param transform the initial transform defining position and rotation + */ + public AbstractCompositeShape(final Transform transform) { + this.transform = transform; + viewSpaceTracker = new ViewSpaceTracker(); + } + + /** + * Adds a sub-shape to this composite shape without a group identifier. + * + * @param shape the shape to add + */ + public void addShape(final AbstractShape shape) { + addShape(shape, null); + } + + /** + * Adds a sub-shape to this composite shape with an optional group identifier. + * + *

Grouped shapes can be shown, hidden, or removed together using + * {@link #showGroup}, {@link #hideGroup}, and {@link #removeGroup}.

+ * + * @param shape the shape to add + * @param groupId the group identifier, or {@code null} for ungrouped shapes + */ + public void addShape(final AbstractShape shape, final String groupId) { + subShapesRegistry.add(new SubShape(shape, groupId, true)); + cacheNeedsRebuild = true; + } + + /** + * This method should be overridden by anyone wanting to customize the shape + * before it is rendered. + * + * @param transformPipe the current transform stack + * @param context the rendering context for the current frame + */ + public void beforeTransformHook(final TransformStack transformPipe, + final RenderingContext context) { + } + + /** + * Returns the world-space position of this composite shape. + * + * @return the translation component of this shape's transform + */ + public Point3D getLocation() { + return transform.getTranslation(); + } + + /** + * Returns the axis-aligned bounding box encompassing all sub-shapes. + * + *

The bounding box is computed by aggregating the bounds of all visible + * sub-shapes, then transforming the result by this composite's own transform.

+ * + *

Caching: The bounding box is recomputed whenever + * {@link #cacheNeedsRebuild} is true (shapes added/removed/visibility changed). + * For nested composites, the bounds include their local transform offset.

+ * + * @return the axis-aligned bounding box in this composite's local coordinates + */ + @Override + public Box getBoundingBox() { + if (cachedBoundingBox == null || cacheNeedsRebuild) { + if (subShapesRegistry.isEmpty()) { + return super.getBoundingBox(); + } + + double minX = Double.MAX_VALUE; + double maxX = -Double.MAX_VALUE; + double minY = Double.MAX_VALUE; + double maxY = -Double.MAX_VALUE; + double minZ = Double.MAX_VALUE; + double maxZ = -Double.MAX_VALUE; + + for (final SubShape subShape : subShapesRegistry) { + if (!subShape.isVisible()) { + continue; + } + + final AbstractShape shape = subShape.getShape(); + final Box shapeBounds = shape.getBoundingBox(); + + // Get bounds and apply sub-shape's transform if it's a composite + Point3D shapeMin = new Point3D(shapeBounds.getMinX(), shapeBounds.getMinY(), shapeBounds.getMinZ()); + Point3D shapeMax = new Point3D(shapeBounds.getMaxX(), shapeBounds.getMaxY(), shapeBounds.getMaxZ()); + if (shape instanceof AbstractCompositeShape) { + final Transform subTransform = ((AbstractCompositeShape) shape).getTransform(); + final Point3D subTranslation = subTransform.getTranslation(); + shapeMin.add(subTranslation); + shapeMax.add(subTranslation); + } + + minX = Math.min(minX, shapeMin.x); + maxX = Math.max(maxX, shapeMax.x); + minY = Math.min(minY, shapeMin.y); + maxY = Math.max(maxY, shapeMax.y); + minZ = Math.min(minZ, shapeMin.z); + maxZ = Math.max(maxZ, shapeMax.z); + } + + if (minX == Double.MAX_VALUE) { + // No visible shapes + return super.getBoundingBox(); + } + + cachedBoundingBox = new Box( + new Point3D(minX, minY, minZ), + new Point3D(maxX, maxY, maxZ) + ); + } + return cachedBoundingBox; + } + + /** + * Returns the sub-shapes registry (source of truth for all sub-shapes). + * + *

This is the authoritative list of all sub-shapes including hidden ones. + * For per-frame rendering, use {@link #cachedRenderList} instead (accessed internally).

+ * + * @return the registry list of all sub-shapes with their group and visibility metadata + * @see #cachedRenderList the frame-optimized cache derived from this registry + */ + public List getSubShapesRegistry() { + return subShapesRegistry; + } + + /** + * Extracts all SolidPolygon instances from this composite shape. + * + *

Recursively traverses the shape hierarchy and collects all + * SolidPolygon instances. Used for CSG operations where polygons + * are needed directly without conversion.

+ * + * @return list of SolidPolygon instances from this shape hierarchy + */ + public List extractSolidPolygons() { + final List result = new ArrayList<>(); + for (final SubShape subShape : subShapesRegistry) { + final AbstractShape shape = subShape.getShape(); + if (shape instanceof SolidPolygon) { + result.add((SolidPolygon) shape); + } else if (shape instanceof AbstractCompositeShape) { + result.addAll(((AbstractCompositeShape) shape).extractSolidPolygons()); + } + } + return result; + } + + /** + * Returns the view-space tracker that monitors the distance + * and angle between the camera and this shape for level-of-detail adjustments. + * + * @return the view-space tracker for this shape + */ + public ViewSpaceTracker getViewSpaceTracker() { + return viewSpaceTracker; + } + + /** + * Hides all sub-shapes belonging to the specified group. + * Hidden shapes are not rendered but remain in the collection. + * + * @param groupIdentifier the group to hide + * @see #showGroup(String) + * @see #removeGroup(String) + */ + public void hideGroup(final String groupIdentifier) { + for (final SubShape subShape : subShapesRegistry) { + if (subShape.matchesGroup(groupIdentifier)) { + subShape.setVisible(false); + cacheNeedsRebuild = true; + } + } + } + + /** + * Permanently removes all sub-shapes belonging to the specified group. + * + * @param groupIdentifier the group to remove + * @see #hideGroup(String) + */ + public void removeGroup(final String groupIdentifier) { + final java.util.Iterator iterator = subShapesRegistry + .iterator(); + + while (iterator.hasNext()) { + final SubShape subShape = iterator.next(); + if (subShape.matchesGroup(groupIdentifier)) { + iterator.remove(); + cacheNeedsRebuild = true; + } + } + } + + /** + * Returns all sub-shapes belonging to the specified group. + * + * @param groupIdentifier the group identifier to match + * @return list of matching sub-shapes + */ + public List getGroup(final String groupIdentifier) { + final List result = new ArrayList<>(); + for (int i = 0; i < subShapesRegistry.size(); i++) { + final SubShape subShape = subShapesRegistry.get(i); + if (subShape.matchesGroup(groupIdentifier)) + result.add(subShape); + } + return result; + } + + /** + * Rebuilds the cached render list if shapes were added, removed, or + * visibility changed since the last rebuild. + * + * @param context the rendering context for logging + */ + private void rebuildRenderListIfNeeded(final RenderingContext context) { + if (cacheNeedsRebuild) + rebuildRenderList(context); + } + + /** + * Paint solid elements of this composite shape into given color. + * + *

Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.

+ * + * @param color the color to apply to all solid sub-shapes + */ + public void setColor(final Color color) { + for (final SubShape subShape : getSubShapesRegistry()) { + final AbstractShape shape = subShape.getShape(); + + if (shape instanceof SolidPolygon) { + ((SolidPolygon) shape).setColor(color); + } else if (shape instanceof Line) { + ((Line) shape).color = color; + } else if (shape instanceof AbstractCompositeShape) { + ((AbstractCompositeShape) shape).setColor(color); + } + } + } + + /** + * Assigns a group identifier to all sub-shapes that currently have no group. + * + * @param groupIdentifier the group to assign to ungrouped shapes + */ + public void setGroupForUngrouped(final String groupIdentifier) { + for (final SubShape subShape : subShapesRegistry) + if (subShape.isUngrouped()) + subShape.setGroup(groupIdentifier); + } + + @Override + public void setMouseInteractionController( + final MouseInteractionController mouseInteractionController) { + super.setMouseInteractionController(mouseInteractionController); + + for (final SubShape subShape : subShapesRegistry) + subShape.getShape().setMouseInteractionController( + mouseInteractionController); + + cacheNeedsRebuild = true; + } + + /** + * Marks this composite as the root scene container. + * + *

Called by {@code ShapeCollection} to configure its root composite.

+ * + * @param isRoot {@code true} if this is the root composite, {@code false} otherwise + */ + public void setRootComposite(final boolean isRoot) { + this.isRootComposite = isRoot; + } + + /** + * Returns this composite's transform (position and orientation). + * + * @return the transform object + */ + public Transform getTransform() { + return transform; + } + + /** + * Sets the transform for this composite shape. + * + * @param transform the new transform + * @return this composite shape (for chaining) + */ + public AbstractCompositeShape setTransform(final Transform transform) { + this.transform = transform; + return this; + } + +/** + * Sets the cache rebuild flag on this composite and all nested composites recursively. + * + *

Used by {@code ShapeCollection} to trigger a render-list rebuild when + * clearing the scene or for other advanced use cases.

+ * + * @param needsRebuild {@code true} to force cache rebuild on next frame + */ + public void setCacheNeedsRebuild(final boolean needsRebuild) { + this.cacheNeedsRebuild = needsRebuild; + // Propagate to nested composites + for (final SubShape subShape : subShapesRegistry) { + final AbstractShape shape = subShape.getShape(); + if (shape instanceof AbstractCompositeShape composite) { + composite.setCacheNeedsRebuild(needsRebuild); + } + } + } + + /** + * Enables or disables shading for all SolidTriangle and SolidPolygon sub-shapes. + * When enabled, shapes use the global lighting manager from the rendering + * context to calculate flat shading based on light sources. + * + *

Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.

+ * + * @param shadingEnabled {@code true} to enable shading, {@code false} to disable + * @return this composite shape (for chaining) + */ + public AbstractCompositeShape setShadingEnabled(final boolean shadingEnabled) { + for (final SubShape subShape : getSubShapesRegistry()) { + final AbstractShape shape = subShape.getShape(); + if (shape instanceof SolidPolygon) { + ((SolidPolygon) shape).setShadingEnabled(shadingEnabled); + } else if (shape instanceof AbstractCompositeShape) { + ((AbstractCompositeShape) shape).setShadingEnabled(shadingEnabled); + } + } + return this; + } + + /** + * Enables or disables backface culling for all SolidPolygon and TexturedTriangle sub-shapes. + * + *

Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.

+ * + * @param backfaceCulling {@code true} to enable backface culling, {@code false} to disable + * @return this composite shape (for chaining) + */ + public AbstractCompositeShape setBackfaceCulling(final boolean backfaceCulling) { + for (final SubShape subShape : getSubShapesRegistry()) { + final AbstractShape shape = subShape.getShape(); + if (shape instanceof SolidPolygon) { + ((SolidPolygon) shape).setBackfaceCulling(backfaceCulling); + } else if (shape instanceof TexturedTriangle) { + ((TexturedTriangle) shape).setBackfaceCulling(backfaceCulling); + } else if (shape instanceof AbstractCompositeShape) { + ((AbstractCompositeShape) shape).setBackfaceCulling(backfaceCulling); + } + } + return this; + } + + /** + * Performs an in-place union with another composite shape. + * + *

This shape's SolidPolygon children are replaced with the union result. + * Non-SolidPolygon children from both shapes are preserved and combined.

+ * + *

CSG Operation: Union combines two shapes into one, keeping all + * geometry from both. Uses BSP tree algorithms for robust boolean operations.

+ * + *

Child handling:

+ *
    + *
  • SolidPolygon children from both shapes → replaced with union result
  • + *
  • Non-SolidPolygon children from this shape → preserved
  • + *
  • Non-SolidPolygon children from other shape → added to this shape
  • + *
  • Nested AbstractCompositeShape children → preserved unchanged (not recursively processed)
  • + *
+ * + * @param other the shape to union with + * @see #subtract(AbstractCompositeShape) + * @see #intersect(AbstractCompositeShape) + */ + public void union(final AbstractCompositeShape other) { + + final BspTree selfTree = new BspTree(clonePolygons(extractSolidPolygons())); + final BspTree otherTree = new BspTree(clonePolygons(other.extractSolidPolygons())); + + // Remove from self any polygons that are inside other (interior faces) + selfTree.clipTo(otherTree); + + // Remove from other any polygons that are inside self (interior faces) + otherTree.clipTo(selfTree); + + // Invert other to convert remaining polygons for the next clip step + otherTree.invert(); + + // Clip inverted other against self to remove back-facing coplanar polygons + otherTree.clipTo(selfTree); + + // Invert back to restore correct polygon orientation + otherTree.invert(); + + // Merge other's remaining polygons into self's BSP tree + selfTree.addPolygons(otherTree.allPolygons()); + + replaceSolidPolygons(selfTree.allPolygons()); + mergeNonPolygonChildrenFrom(other); + } + + /** + * Performs an in-place subtraction with another composite shape. + * + *

This shape's SolidPolygon children are replaced with the difference result. + * The other shape acts as a "cutter" that carves out volume from this shape.

+ * + *

CSG Operation: Subtract removes the volume of the second shape + * from the first shape. Useful for creating holes, cavities, and cutouts.

+ * + *

Child handling:

+ *
    + *
  • SolidPolygon children from this shape → replaced with difference result
  • + *
  • Non-SolidPolygon children from this shape → preserved
  • + *
  • All children from other shape → discarded (other is just a cutter)
  • + *
  • Nested AbstractCompositeShape children → preserved unchanged
  • + *
+ * + * @param other the shape to subtract (the cutter) + * @see #union(AbstractCompositeShape) + * @see #intersect(AbstractCompositeShape) + */ + public void subtract(final AbstractCompositeShape other) { + + final BspTree target = new BspTree(clonePolygons(extractSolidPolygons())); + final BspTree cutter = new BspTree(clonePolygons(other.extractSolidPolygons())); + + // Invert target: convert "inside" to "outside" and vice versa + // This transforms the problem from "subtract B from A" to "intersect A's complement with B's complement" + target.invert(); + + // Clip target against cutter: removes parts of target that are INSIDE the cutter + // Since target is inverted, this removes parts that were OUTSIDE the original target + target.clipTo(cutter); + + // Clip cutter against (inverted) target: removes parts of cutter outside the inverted target + // This keeps only cutter polygons that are inside the inverted target = outside original target + cutter.clipTo(target); + + // Invert cutter to flip its inside/outside + cutter.invert(); + + // Clip inverted cutter against target: removes coplanar back-faces + cutter.clipTo(target); + + // Invert cutter back to correct orientation + cutter.invert(); + + // Merge cutter's polygons into target's BSP tree + target.addPolygons(cutter.allPolygons()); + + // Invert target back to restore correct inside/outside orientation + // Result: the carved-out volume (target minus cutter) + target.invert(); + + replaceSolidPolygons(target.allPolygons()); + } + + /** + * Performs an in-place intersection with another composite shape. + * + *

This shape's SolidPolygon children are replaced with the intersection result. + * Only the overlapping volume between the two shapes remains.

+ * + *

CSG Operation: Intersect keeps only the volume where both shapes + * overlap. Useful for creating shapes constrained by multiple boundaries.

+ * + *

Child handling:

+ *
    + *
  • SolidPolygon children from this shape → replaced with intersection result
  • + *
  • Non-SolidPolygon children from this shape → preserved
  • + *
  • All children from other shape → discarded
  • + *
  • Nested AbstractCompositeShape children → preserved unchanged
  • + *
+ * + * @param other the shape to intersect with + * @see #union(AbstractCompositeShape) + * @see #subtract(AbstractCompositeShape) + */ + public void intersect(final AbstractCompositeShape other) { + + final BspTree selfTree = new BspTree(clonePolygons(extractSolidPolygons())); + final BspTree otherTree = new BspTree(clonePolygons(other.extractSolidPolygons())); + + // Invert self to convert "inside" to "outside" + // This transforms intersection into: keep parts that are "outside both inverted shapes" + selfTree.invert(); + + // Clip other against inverted self: keeps only parts of other that are INSIDE original self + // (because clipTo removes what's "outside" the BSP, and inverted self's "outside" = original self's "inside") + otherTree.clipTo(selfTree); + + // Invert other (which now represents the intersection region) + otherTree.invert(); + + // Clip inverted self against (inverted intersection): removes parts outside the intersection + selfTree.clipTo(otherTree); + + // Clip intersection result against inverted self: removes back-facing coplanar polygons + otherTree.clipTo(selfTree); + + // Build final BSP tree from the clipped intersection polygons + selfTree.addPolygons(otherTree.allPolygons()); + + // Invert back to restore correct inside/outside orientation + selfTree.invert(); + + replaceSolidPolygons(selfTree.allPolygons()); + } + + /** + * Creates deep clones of all polygons in the list. + * + *

CSG operations modify polygons in-place via BSP tree operations. + * Cloning ensures the original polygon data is preserved.

+ * + * @param polygons the polygons to clone + * @return a new list containing deep clones of all polygons + */ + private List clonePolygons(final List polygons) { + final List cloned = new ArrayList<>(polygons.size()); + for (final SolidPolygon p : polygons) { + cloned.add(p.deepClone()); + } + return cloned; + } + + /** + * Replaces this shape's SolidPolygon children with new polygons. + * + *

Preserves all non-SolidPolygon children (Lines, nested composites, etc.).

+ * + * @param newPolygons the polygons to replace with + */ + private void replaceSolidPolygons(final List newPolygons) { + // Remove all direct SolidPolygon children from this shape + final Iterator iterator = subShapesRegistry.iterator(); + while (iterator.hasNext()) { + final SubShape subShape = iterator.next(); + if (subShape.getShape() instanceof SolidPolygon) { + iterator.remove(); + } + } + + // Add all result polygons as new children + for (final SolidPolygon polygon : newPolygons) { + addShape(polygon); + } + + cacheNeedsRebuild = true; + } + + /** + * Merges non-SolidPolygon children from another shape into this shape. + * + *

Copies all non-SolidPolygon children (Lines, nested composites, etc.) + * from the other shape, preserving their group identifiers.

+ * + * @param other the shape to merge non-polygon children from + */ + private void mergeNonPolygonChildrenFrom(final AbstractCompositeShape other) { + if (other == null) { + return; + } + + for (final SubShape otherSubShape : other.subShapesRegistry) { + final AbstractShape otherShape = otherSubShape.getShape(); + if (!(otherShape instanceof SolidPolygon)) { + addShape(otherShape, otherSubShape.getGroupIdentifier()); + } + } + + cacheNeedsRebuild = true; + } + + /** + * Makes all sub-shapes belonging to the specified group visible. + * + * @param groupIdentifier the group to show + * @see #hideGroup(String) + */ + public void showGroup(final String groupIdentifier) { + for (int i = 0; i < subShapesRegistry.size(); i++) { + final SubShape subShape = subShapesRegistry.get(i); + if (subShape.matchesGroup(groupIdentifier)) { + subShape.setVisible(true); + cacheNeedsRebuild = true; + } + } + } + + /** + * Rebuilds the cached render list from the shape registry: + * textured triangles pass through as-is (perspective-correct scanline + * rendering needs no tessellation), N-vertex solid polygons are + * fan-triangulated, everything else passes through. + * Logs the operation to the debug log buffer if available. + * + * @param context the rendering context for logging, may be {@code null} + */ + private void rebuildRenderList(final RenderingContext context) { + cacheNeedsRebuild = false; + + final List result = new ArrayList<>(); + int texturedPolygonCount = 0; + int solidPolygonCount = 0; + int triangulatedPolygonCount = 0; + int otherShapeCount = 0; + + for (int i = 0; i < subShapesRegistry.size(); i++) { + final SubShape subShape = subShapesRegistry.get(i); + if (!subShape.isVisible()) + continue; + + final AbstractShape shape = subShape.getShape(); + + if (shape instanceof TexturedTriangle) { + result.add(shape); + texturedPolygonCount++; + } else if (shape instanceof SolidPolygon polygon) { + final int vertexCount = polygon.getVertexCount(); + + if (vertexCount == 3) { + result.add(polygon); + solidPolygonCount++; + } else { + triangulateSolidPolygon(polygon, result); + triangulatedPolygonCount++; + } + } else { + result.add(shape); + otherShapeCount++; + } + } + + cachedRenderList = postprocessRenderList(result); + renderListVersion++; + globalRenderListVersion.incrementAndGet(); + + if (context != null && context.debugLogBuffer != null) { + context.debugLogBuffer.log("rebuildRenderList: " + getClass().getSimpleName() + + " texturedPolygons=" + texturedPolygonCount + + " solidPolygons=" + solidPolygonCount + + " triangulatedPolygons=" + triangulatedPolygonCount + + " otherShapes=" + otherShapeCount); + } + } + + /** + * Returns the global render list version: incremented every time ANY + * composite's render list is rebuilt. Used by derived structures + * (BSP trees, GI scene snapshots) to detect that they must rebuild. + * + * @return monotonically increasing global version + */ + public static int getGlobalRenderListVersion() { + return globalRenderListVersion.get(); + } + + /** + * Collects the triangles of this composite's current render list, + * recursing into nested composites. These are the exact objects that + * get transformed and rendered: triangulated render-list polygons, or + * lightmapped wrappers for + * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}. + * + *

Render lists are built lazily during transform; composites that + * have not been transformed yet (or are frustum-culled) contribute + * nothing. Vertices are in each composite's local space — callers + * combining several composites should require identity transforms.

+ * + * @param out list receiving the triangles + */ + public void collectRenderTriangles(final List out) { + if (cachedRenderList == null) + return; + for (final AbstractShape shape : cachedRenderList) { + if (shape instanceof SolidPolygon + || shape instanceof eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle) + out.add((AbstractCoordinateShape) shape); + else if (shape instanceof AbstractCompositeShape) + ((AbstractCompositeShape) shape).collectRenderTriangles(out); + } + } + + /** + * Hook: post-processes the freshly rebuilt render list before it becomes + * the rendering cache. The default implementation returns the list + * unchanged. Subclasses may replace the list — e.g. + * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape} + * wraps its polygons into lightmapped triangles here. + * + * @param renderList the render list built from the shape registry + * @return the render list to cache and render + */ + protected List postprocessRenderList(final List renderList) { + return renderList; + } + + /** + * Triangulates a convex solid polygon using fan triangulation. + * + *

Fan triangulation creates N-2 triangles from an N-vertex polygon by using + * vertex 0 as the anchor and connecting it to each adjacent pair of vertices.

+ * + *

Properties (color, shading, backface culling, mouse interaction) are + * propagated to each resulting triangle to ensure consistent behavior.

+ * + * @param polygon the polygon to triangulate (must have at least 4 vertices) + * @param result the list to add the resulting triangles to + */ + private void triangulateSolidPolygon(final SolidPolygon polygon, + final List result) { + + final Color color = polygon.getColor(); + final boolean shadingEnabled = polygon.isShadingEnabled(); + final boolean backfaceCulling = polygon.isBackfaceCullingEnabled(); + final MouseInteractionController mouseController = polygon.mouseInteractionController; + + final List vertices = polygon.vertices; + final Vertex v0 = vertices.get(0); + + for (int i = 1; i < vertices.size() - 1; i++) { + final Vertex v1 = vertices.get(i); + final Vertex v2 = vertices.get(i + 1); + + final SolidPolygon triangle = new SolidPolygon( + v0.coordinate, v1.coordinate, v2.coordinate, color); + + triangle.setShadingEnabled(shadingEnabled); + triangle.setBackfaceCulling(backfaceCulling); + triangle.setMouseInteractionController(mouseController); + + result.add(triangle); + } + } + + @Override + public void transform(final TransformStack transformPipe, + final RenderAggregator aggregator, final RenderingContext context) { + + // Add the current composite shape transform to the end of the transform + // pipeline. + transformPipe.addTransform(transform); + + // FRUSTUM CULLING: Check if this composite's bounds are visible + // Root composite skips this check (its bounds are always the full scene) + // Non-root composites check their aggregated bounds against the frustum + if (context.frustum != null && !isRootComposite) { + // Count this composite for culling statistics (before frustum test) + if (context.cullingStatistics != null) { + context.cullingStatistics.totalComposites.incrementAndGet(); + } + + final Box localBounds = getBoundingBox(); + + // Transform all 8 corners of the bounding box to view space + final double minX = localBounds.getMinX(); + final double maxX = localBounds.getMaxX(); + final double minY = localBounds.getMinY(); + final double maxY = localBounds.getMaxY(); + final double minZ = localBounds.getMinZ(); + final double maxZ = localBounds.getMaxZ(); + + final double[] xs = {minX, maxX}; + final double[] ys = {minY, maxY}; + final double[] zs = {minZ, maxZ}; + + double viewMinX = Double.MAX_VALUE; + double viewMaxX = -Double.MAX_VALUE; + double viewMinY = Double.MAX_VALUE; + double viewMaxY = -Double.MAX_VALUE; + double viewMinZ = Double.MAX_VALUE; + double viewMaxZ = -Double.MAX_VALUE; + + for (int i = 0; i < 8; i++) { + final double x = xs[(i & 1)]; + final double y = ys[(i >> 1) & 1]; + final double z = zs[(i >> 2) & 1]; + + final Point3D corner = transformPointToViewSpace(x, y, z, transformPipe); + + viewMinX = Math.min(viewMinX, corner.x); + viewMaxX = Math.max(viewMaxX, corner.x); + viewMinY = Math.min(viewMinY, corner.y); + viewMaxY = Math.max(viewMaxY, corner.y); + viewMinZ = Math.min(viewMinZ, corner.z); + viewMaxZ = Math.max(viewMaxZ, corner.z); + } + + final Box viewSpaceBounds = new Box( + new Point3D(viewMinX, viewMinY, viewMinZ), + new Point3D(viewMaxX, viewMaxY, viewMaxZ) + ); + + final Frustum frustum = context.frustum; + final boolean visible = frustum.intersectsAABB(viewSpaceBounds); + + if (!visible) { + // Entire composite outside frustum - skip processing all children + if (context.cullingStatistics != null) { + context.cullingStatistics.culledComposites.incrementAndGet(); + } + transformPipe.dropTransform(); + return; + } + } + + viewSpaceTracker.analyze(transformPipe, context); + + beforeTransformHook(transformPipe, context); + + rebuildRenderListIfNeeded(context); + + // transform rendered subshapes + if (shouldForkTransform(context)) { + transformChildrenParallel(transformPipe, aggregator, context); + } else { + transformChildrenSerial(transformPipe, aggregator, context); + } + + transformPipe.dropTransform(); + } + + /** + * Minimum number of children before the parallel fork is considered at + * all. A single child cannot be split; splitting happens in the child. + */ + private static final int PARALLEL_TRANSFORM_MIN_SHAPES = 2; + + /** + * Minimum total subtree weight (leaf primitives below this composite) + * before forking its transform into parallel chunks. Below this, the + * serial walk is cheaper than the fork overhead. Deliberately above + * one sphere's generated triangle count (~960 at 16 segments): + * measured 2026-09-04, forking those pays task overhead per chunk for + * negligible serial work (35k chunk tasks on a 3000-sphere scene were + * SLOWER than serial). + */ + private static final int PARALLEL_TRANSFORM_MIN_SUBTREE_WEIGHT = 2048; + + /** + * Minimum weight per parallel chunk task. Keeps chunk granularity + * coarse enough that task dispatch overhead stays negligible. + */ + private static final int PARALLEL_TASK_MIN_WEIGHT = 512; + + /** + * Cached subtree weight from {@link #getTransformWeight}, valid for + * {@link #subtreeWeightCycle} only. + */ + private int cachedSubtreeWeight; + + /** + * Transform cycle id the cached subtree weight was computed on. + * Keyed on the globally unique cycle id, not the per-context frame + * number, so alternating between rendering contexts cannot produce + * stale cache hits. + */ + private long subtreeWeightCycle = -1; + + /** + * Transform cycle id the cached subtree weight was last RECOMPUTED on. + * Separate from {@link #subtreeWeightCycle} (last read): the refresh + * gate measures the age of the computation, not of the last access. + */ + private long subtreeWeightComputeCycle = -1; + + /** + * {@link #renderListVersion} at the last weight recomputation. + */ + private int weightListVersion = -1; + + /** + * Bumped whenever ANY composite rebuilds its render list. Lets the + * weight shortcut react to structural changes anywhere in the tree + * within one cycle, at O(1) per node per cycle — scanning direct + * children's versions instead costs O(leaves) per frame because leaf + * lists dominate (measured 2026-09-04: +3-4 ms/frame on a 400-sphere + * scene). + */ + private static final AtomicInteger globalRenderListVersion = new AtomicInteger(); + + /** + * {@link #globalRenderListVersion} value seen at the last weight + * recomputation. + */ + private int weightGlobalVersion = -1; + + /** + * Bumped every time {@link #cachedRenderList} is rebuilt. Gates weight + * recomputation: while the list is unchanged, the cached weight is + * reused without re-walking the subtree. + */ + private int renderListVersion; + + /** + * Full subtree weight re-walks are O(total leaves below this node), + * which costs real milliseconds on big meshes. With an unchanged render + * list the cached weight is refreshed at most every this many cycles; + * structural changes (rebuilds) recompute immediately. + */ + private static final long WEIGHT_REFRESH_CYCLES = 16; + + /** + * Total transform weight of this composite: the sum of its children's + * weights, i.e. roughly the number of leaf primitives below it. + * Computed lazily; recomputed only when this node's render list was + * rebuilt or the cache is older than {@link #WEIGHT_REFRESH_CYCLES} + * cycles (children's internal rebuilds are picked up by the periodic + * refresh). Used solely for parallel fork load balancing, never for + * correctness, so brief staleness is harmless. + * + *

Thread safety: a composite's fork decision runs on exactly one + * thread per cycle. Chunk-thread reads are cycle-stamped cache hits + * published through the executor's happens-before edge.

+ * + * @param renderingContext the rendering context (cycle identity) + * @return subtree transform weight, at least 1 + */ + @Override + public int getTransformWeight(final RenderingContext renderingContext) { + final long cycle = renderingContext.transformCycleId; + if (subtreeWeightCycle == cycle) { + return cachedSubtreeWeight; + } + final int globalVersion = globalRenderListVersion.get(); + if (weightListVersion == renderListVersion + && weightGlobalVersion == globalVersion + && cycle - subtreeWeightComputeCycle < WEIGHT_REFRESH_CYCLES) { + // Nothing rebuilt anywhere and computation fresh: keep the + // value, just re-stamp the read. O(1) per node per cycle. + subtreeWeightCycle = cycle; + return cachedSubtreeWeight; + } + int weight = 0; + for (final AbstractShape child : cachedRenderList) { + weight += child.getTransformWeight(renderingContext); + } + cachedSubtreeWeight = Math.max(1, weight); + subtreeWeightCycle = cycle; + subtreeWeightComputeCycle = cycle; + weightListVersion = renderListVersion; + weightGlobalVersion = globalVersion; + return cachedSubtreeWeight; + } + + /** + * Decides whether this composite forks its children's transform into + * parallel chunks: enough children to split, and enough TOTAL weight + * below it to amortize the fork overhead. Weight (not local child + * count) is what matters: a deep narrow tree with two heavy children + * forks just like a flat mesh with thousands of leaves. + * + * @param context the rendering context (provides the coordinator) + * @return true when the parallel fork should be taken + */ + private boolean shouldForkTransform(final RenderingContext context) { + if (context.transformCoordinator == null) { + return false; + } + if (cachedRenderList.size() < PARALLEL_TRANSFORM_MIN_SHAPES) { + return false; + } + return getTransformWeight(context) >= PARALLEL_TRANSFORM_MIN_SUBTREE_WEIGHT; + } + + /** + * Target number of task chunks per available processor core. + * More tasks than cores gives the pool load balancing across + * shapes with uneven transform cost. + */ + private static final int PARALLEL_TASKS_PER_CORE = 4; + + /** + * Transforms all children serially on the calling thread. + * + * @param transformPipe the transform stack (includes this composite's transform) + * @param aggregator the aggregator to queue visible shapes into + * @param context the rendering context + */ + private void transformChildrenSerial(final TransformStack transformPipe, + final RenderAggregator aggregator, + final RenderingContext context) { + for (final AbstractShape shape : cachedRenderList) { + shape.transform(transformPipe, aggregator, context); + } + } + + /** + * Forks the children's transform into parallel chunk tasks on the + * frame's {@link ParallelTransformCoordinator} and returns immediately + * WITHOUT waiting for them. + * + *

Works at any nesting level: a heavy composite reached inside a + * chunk task forks its own children into the same coordinator. This is + * deadlock-safe because chunk tasks never block on other tasks; only + * the orchestrating render thread waits (in the coordinator's drain).

+ * + *

Stack snapshotting: this composite's transform is dropped from + * {@code transformPipe} right after this method returns, long before + * the chunk tasks run, so the pipe is copied HERE on the forking + * thread. Each task then copies the snapshot for its own working stack. + * The snapshot is never mutated after publication, so concurrent + * copying by chunk tasks is safe.

+ * + *

Thread-safety notes: sibling composites are exclusively owned by + * one chunk, so their per-instance caches (render list, bounding + * boxes, own transform's cached matrix) never race. The vertex + * frameNumber cache is a benign race: every thread writes the same + * value.

+ * + * @param transformPipe the transform stack (includes this composite's transform) + * @param aggregator unused in the parallel path: per-task aggregators + * are merged by the coordinator's drain + * @param context the rendering context (provides the coordinator) + */ + private void transformChildrenParallel(final TransformStack transformPipe, + final RenderAggregator aggregator, + final RenderingContext context) { + // Snapshot the render list reference. With the pipelined render + // loop, the NEXT pass's tree walk can already be running while + // this pass's chunk tasks are still queued (the drain happens in + // the async continuation, not before the next walk). That walk + // may rebuild this composite's render list, REASSIGNING + // cachedRenderList to a new list of a different size. The chunk + // ranges below are computed against this list instance, so the + // chunk tasks must index this same instance — re-reading the + // field inside the lambda raced with the rebuild and threw + // IndexOutOfBoundsException. (The old list stays alive and valid + // for this pass; each pass transforms into its own vertex slot.) + final List renderList = cachedRenderList; + final int size = renderList.size(); + final int totalWeight = getTransformWeight(context); + final int processors = Runtime.getRuntime().availableProcessors(); + final int targetTasks = processors * PARALLEL_TASKS_PER_CORE; + final int taskWeight = Math.max(PARALLEL_TASK_MIN_WEIGHT, + (totalWeight + targetTasks - 1) / targetTasks); + + // Pass 1: count chunks, cutting by ACCUMULATED WEIGHT so that a + // node with few but heavy children (e.g. two 25k-triangle halves + // of a fractal) still splits into multiple tasks + int taskCount = 0; + int accumulated = 0; + for (int i = 0; i < size; i++) { + accumulated += renderList.get(i).getTransformWeight(context); + if (accumulated >= taskWeight) { + taskCount++; + accumulated = 0; + } + } + if (accumulated > 0) { + taskCount++; + } + + if (taskCount < 2) { + transformChildrenSerial(transformPipe, aggregator, context); + return; + } + + final ParallelTransformCoordinator coordinator = context.transformCoordinator; + if (!coordinator.tryReserveTasks(taskCount)) { + // Frame-wide task budget exhausted: transform inline + transformChildrenSerial(transformPipe, aggregator, context); + return; + } + + final TransformStack snapshot = new TransformStack(transformPipe); + + // Pass 2: submit chunks (weights are frame-cached, cheap re-walk) + int from = 0; + accumulated = 0; + for (int i = 0; i < size; i++) { + accumulated += renderList.get(i).getTransformWeight(context); + if (accumulated >= taskWeight || i == size - 1) { + final int chunkFrom = from; + final int chunkTo = i + 1; + coordinator.submit(() -> { + // Pooled scratch: the stack (9.6 KB of arrays) and + // the chunk aggregator (queue keeps its capacity + // across frames) come from the coordinator's static + // pools — previously each chunk allocated both on + // every frame. + final TransformStack taskStack = + coordinator.borrowStack(snapshot); + try { + final RenderAggregator taskAggregator = + coordinator.borrowAggregator(); + for (int c = chunkFrom; c < chunkTo; c++) { + renderList.get(c).transform(taskStack, taskAggregator, context); + } + return taskAggregator; + } finally { + coordinator.returnStack(taskStack); + } + }); + from = i + 1; + accumulated = 0; + } + } + } + + /** + * Transforms a point to view space using the current transform stack. + * Helper method for frustum culling that transforms bounding box corners. + * + * @param x the X coordinate in local space + * @param y the Y coordinate in local space + * @param z the Z coordinate in local space + * @param transformPipe the current transform stack + * @return the transformed point in view space + */ + private Point3D transformPointToViewSpace(final double x, final double y, final double z, + final TransformStack transformPipe) { + final Point3D input = new Point3D(x, y, z); + final Point3D result = new Point3D(); + transformPipe.transform(input, result); + return result; + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/SubShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/SubShape.java new file mode 100644 index 0000000..19ed0d0 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/SubShape.java @@ -0,0 +1,128 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base; + +import java.util.Objects; + +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape; + +/** + * Wrapper around an {@link AbstractShape} within an {@link AbstractCompositeShape}, + * adding group membership and visibility control. + * + *

Sub-shapes can be organized into named groups so they can be shown, hidden, + * or removed together. This is useful for toggling parts of a composite shape, + * such as showing/hiding labels, highlights, or selection borders.

+ * + * @see AbstractCompositeShape#addShape(AbstractShape, String) + * @see AbstractCompositeShape#hideGroup(String) + * @see AbstractCompositeShape#showGroup(String) + */ +public class SubShape { + + /** + * The wrapped shape that belongs to the parent composite shape. + * This is the actual renderable geometry (line, polygon, etc.). + */ + private final AbstractShape shape; + + /** + * Whether this sub-shape should be rendered. + * Hidden shapes remain in the composite but are excluded from rendering. + */ + private boolean visible = true; + + /** + * The group identifier for batch visibility operations. + * {@code null} indicates this shape is not part of any named group. + */ + private String groupIdentifier; + + /** + * Creates a sub-shape wrapper around the given shape with default visibility (visible). + * + * @param shape the shape to wrap + */ + public SubShape(final AbstractShape shape) { + this(shape, null, true); + } + + /** + * Creates a sub-shape with all properties specified. + * + * @param shape the shape to wrap + * @param groupIdentifier the group identifier, or {@code null} for ungrouped + * @param visible whether the shape is initially visible + */ + public SubShape(final AbstractShape shape, final String groupIdentifier, final boolean visible) { + this.shape = shape; + this.groupIdentifier = groupIdentifier; + this.visible = visible; + } + + /** + * Returns {@code true} if this sub-shape has no group assigned. + * + * @return {@code true} if ungrouped + */ + public boolean isUngrouped() { + return groupIdentifier == null; + } + + /** + * Checks whether this sub-shape belongs to the specified group. + * + * @param groupIdentifier the group identifier to match against, or {@code null} to match ungrouped shapes + * @return {@code true} if this sub-shape belongs to the specified group + */ + public boolean matchesGroup(final String groupIdentifier) { + return Objects.equals(this.groupIdentifier, groupIdentifier); + } + + /** + * Returns the group identifier for this sub-shape. + * + * @return the group identifier, or {@code null} if this shape is ungrouped + */ + public String getGroupIdentifier() { + return groupIdentifier; + } + + /** + * Assigns this sub-shape to a group. + * + * @param groupIdentifier the group identifier, or {@code null} to make it ungrouped + */ + public void setGroup(final String groupIdentifier) { + this.groupIdentifier = groupIdentifier; + } + + /** + * Returns the wrapped shape. + * + * @return the underlying shape + */ + public AbstractShape getShape() { + return shape; + } + + /** + * Returns whether this sub-shape is currently visible and will be rendered. + * + * @return {@code true} if visible + */ + public boolean isVisible() { + return visible; + } + + /** + * Sets the visibility of this sub-shape. + * + * @param visible {@code true} to make the shape visible, {@code false} to hide it + */ + public void setVisible(boolean visible) { + this.visible = visible; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/package-info.java new file mode 100644 index 0000000..a35e03c --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/package-info.java @@ -0,0 +1,24 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Base class and utilities for composite shapes. + * + *

{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape} + * is the foundation for building complex 3D objects by grouping primitives.

+ * + *

Features:

+ *
    + *
  • Position and rotation in 3D space
  • + *
  • Named groups for selective visibility
  • + *
  • Automatic sub-shape management
  • + *
  • Integration with lighting and slicing
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.SubShape + */ + +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/package-info.java new file mode 100644 index 0000000..d75f5eb --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/package-info.java @@ -0,0 +1,23 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Composite shapes that group multiple primitives into compound 3D objects. + * + *

Composite shapes allow building complex objects from simpler primitives. + * They support grouping, visibility toggling, and hierarchical transformations.

+ * + *

Subpackages:

+ *
    + *
  • {@code base} - Base class for all composite shapes
  • + *
  • {@code solid} - Solid objects (cubes, spheres, cylinders)
  • + *
  • {@code wireframe} - Wireframe objects (boxes, grids, spheres)
  • + *
  • {@code textcanvas} - 3D text rendering canvas
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape + */ + +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonArrow.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonArrow.java new file mode 100644 index 0000000..072a8b3 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonArrow.java @@ -0,0 +1,324 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.math.Matrix3x3; +import eu.svjatoslav.aukio.e3d.math.Quaternion; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A 3D arrow shape composed of a cylindrical body and a conical tip. + * + *

The arrow points from a start point to an end point, with the tip + * located at the end point. The arrow's appearance (size, color, transparency) + * can be customized through the constructor parameters.

+ * + *

Usage example:

+ *
{@code
+ * // Create a red arrow pointing from origin to (100, -50, 200)
+ * SolidPolygonArrow arrow = new SolidPolygonArrow(
+ *     new Point3D(0, 0, 0),      // start point
+ *     new Point3D(100, -50, 200), // end point
+ *     8,                         // body radius
+ *     20,                        // tip radius
+ *     40,                        // tip length
+ *     16,                        // segments
+ *     Color.RED                  // color
+ * );
+ * shapeCollection.addShape(arrow);
+ *
+ * // Create a semi-transparent blue arrow
+ * SolidPolygonArrow seeThroughArrow = new SolidPolygonArrow(
+ *     new Point3D(0, 100, 0),
+ *     new Point3D(0, -100, 0),
+ *     10, 25, 50, 12,
+ *     new Color(0, 0, 255, 128)  // blue with 50% transparency
+ * );
+ * }
+ * + * @see SolidPolygonCone + * @see SolidPolygonCylinder + */ +public class SolidPolygonArrow extends AbstractCompositeShape { + + /** + * + * Number of segments for arrow smoothness. + */ + private static final int SEGMENTS = 12; + + /** + * Arrow tip radius as a fraction of body radius (2.5x). + */ + private static final double TIP_RADIUS_FACTOR = 2.5; + + /** + * Arrow tip length as a fraction of body radius (5.0x). + */ + private static final double TIP_LENGTH_FACTOR = 5.0; + + /** + * Constructs a 3D arrow pointing from start to end with sensible defaults. + * + *

This simplified constructor automatically calculates the tip radius as + * 2.5 times the body radius, the tip length as 5 times the body radius, and + * uses 12 segments for smoothness. For custom tip dimensions or segment count, + * use the full constructor.

+ * + * @param startPoint the origin point of the arrow (where the body starts) + * @param endPoint the destination point of the arrow (where the tip points to) + * @param bodyRadius the radius of the cylindrical body; tip dimensions are + * calculated automatically from this value + * @param color the fill color (RGBA; alpha controls transparency) + */ + public SolidPolygonArrow(final Point3D startPoint, final Point3D endPoint, + final double bodyRadius, final Color color) { + super(); + + // Calculate direction and distance + final double dx = endPoint.x - startPoint.x; + final double dy = endPoint.y - startPoint.y; + final double dz = endPoint.z - startPoint.z; + final double distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + + // Handle degenerate case: start and end are the same point + if (distance < 0.001) { + return; + } + + // Normalize direction vector + final double nx = dx / distance; + final double ny = dy / distance; + final double nz = dz / distance; + + // Calculate rotation to align Y-axis with direction + // Default arrow points in -Y direction (apex at lower Y) + // We need to rotate from (0, -1, 0) to (nx, ny, nz) + final Quaternion rotation = createRotationFromYAxis(nx, ny, nz); + final Matrix3x3 rotMatrix = rotation.toMatrix(); + + // Calculate body length (distance minus tip) + final double bodyLength = Math.max(0, distance - bodyRadius * TIP_LENGTH_FACTOR); + + // Build the arrow components + if (bodyLength > 0) { + addCylinderBody(startPoint, bodyRadius, bodyLength, SEGMENTS, color, rotMatrix, nx, ny, nz); + } + addConeTip(endPoint, bodyRadius * TIP_RADIUS_FACTOR, bodyRadius * TIP_LENGTH_FACTOR, SEGMENTS, color, rotMatrix, nx, ny, nz); + + setBackfaceCulling(true); + } + + /** + * Creates a quaternion that rotates from the -Y axis to the given direction. + * + *

The arrow by default points in the -Y direction. This method computes + * the rotation needed to align the arrow with the target direction vector.

+ * + * @param nx normalized direction X component + * @param ny normalized direction Y component + * @param nz normalized direction Z component + * @return quaternion representing the rotation + */ + private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) { + // Default direction is -Y (0, -1, 0) + // Target direction is (nx, ny, nz) + // Dot product: 0*nx + (-1)*ny + 0*nz = -ny + final double dot = -ny; + + // Check for parallel vectors + if (dot > 0.9999) { + // Direction is nearly -Y, no rotation needed + return Quaternion.identity(); + } + if (dot < -0.9999) { + // Direction is nearly +Y, rotate 180° around X axis + return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI); + } + + // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx) + // This gives the rotation axis + final double axisX = -nz; + final double axisY = 0; + final double axisZ = nx; + final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ); + final double normalizedAxisX = axisX / axisLength; + final double normalizedAxisZ = axisZ / axisLength; + + // Angle from dot product + final double angle = Math.acos(dot); + + return Quaternion.fromAxisAngle( + new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle); + } + + /** + * Adds the cylindrical body of the arrow. + * + *

The cylinder is created with its base at the start point and extends + * in the direction of the arrow for the specified body length.

+ * + *

Local coordinate system: The arrow points in -Y direction in local space. + * Therefore, local -Y is toward the tip (front), and local +Y is toward the start (back).

+ * + * @param startPoint the origin of the arrow body + * @param radius the radius of the cylinder + * @param length the length of the cylinder + * @param segments the number of segments around the circumference + * @param color the fill color + * @param rotMatrix the rotation matrix to apply + * @param dirX direction X component (for translation calculation) + * @param dirY direction Y component + * @param dirZ direction Z component + */ + private void addCylinderBody(final Point3D startPoint, final double radius, + final double length, final int segments, + final Color color, final Matrix3x3 rotMatrix, + final double dirX, final double dirY, final double dirZ) { + // Cylinder center is at startPoint + (length/2) * direction + final double centerX = startPoint.x + (length / 2.0) * dirX; + final double centerY = startPoint.y + (length / 2.0) * dirY; + final double centerZ = startPoint.z + (length / 2.0) * dirZ; + + // Generate ring vertices in local space, then rotate and translate + // Arrow points in -Y direction, so: + // - tipSideRing is at local -Y (toward arrow tip, front of cylinder) + // - startSideRing is at local +Y (toward arrow start, back of cylinder) + final Point3D[] tipSideRing = new Point3D[segments]; + final Point3D[] startSideRing = new Point3D[segments]; + + final double halfLength = length / 2.0; + + for (int i = 0; i < segments; i++) { + final double angle = 2.0 * Math.PI * i / segments; + final double localX = radius * Math.cos(angle); + final double localZ = radius * Math.sin(angle); + + // Tip-side ring (at -halfLength in local Y = toward arrow tip) + final Point3D tipSideLocal = new Point3D(localX, -halfLength, localZ); + rotMatrix.transform(tipSideLocal, tipSideLocal); + tipSideLocal.x += centerX; + tipSideLocal.y += centerY; + tipSideLocal.z += centerZ; + tipSideRing[i] = tipSideLocal; + + // Start-side ring (at +halfLength in local Y = toward arrow start) + final Point3D startSideLocal = new Point3D(localX, halfLength, localZ); + rotMatrix.transform(startSideLocal, startSideLocal); + startSideLocal.x += centerX; + startSideLocal.y += centerY; + startSideLocal.z += centerZ; + startSideRing[i] = startSideLocal; + } + + // Create cylinder side faces (one quad per segment) + // Winding: tipSide[i] → startSide[i] → startSide[next] → tipSide[next] + // creates CCW winding when viewed from outside the cylinder + for (int i = 0; i < segments; i++) { + final int next = (i + 1) % segments; + + addShape(SolidPolygon.quad( + tipSideRing[i], + startSideRing[i], + startSideRing[next], + tipSideRing[next], + color)); + } + + // Add back cap at the start point. + // Single N-vertex polygon that closes the loop to create segments triangles + // (segments+2 vertices → segments triangles via fan triangulation) + // The cap faces backward (away from arrow tip), opposite to arrow direction. + // Winding: center → ring[segments-1] → ... → ring[1] → ring[0] → ring[segments-1] + // (reverse order from ring array direction) + final Point3D[] backCapVertices = new Point3D[segments + 2]; + backCapVertices[0] = startPoint; + for (int i = 0; i < segments; i++) { + backCapVertices[i + 1] = startSideRing[segments - 1 - i]; + } + backCapVertices[segments + 1] = startSideRing[segments - 1]; // close the loop + addShape(new SolidPolygon(backCapVertices, color)); + } + + /** + * Adds the conical tip of the arrow. + * + *

The cone is created with its apex at the end point (the arrow tip) + * and its base pointing back towards the start point.

+ * + *

Local coordinate system: In local space, the cone points in -Y direction + * (apex at lower Y). The base ring is at Y=0, and the apex is at Y=-length.

+ * + * @param endPoint the position of the arrow tip (cone apex) + * @param radius the radius of the cone base + * @param length the length of the cone + * @param segments the number of segments around the circumference + * @param color the fill color + * @param rotMatrix the rotation matrix to apply + * @param dirX direction X component + * @param dirY direction Y component + * @param dirZ direction Z component + */ + private void addConeTip(final Point3D endPoint, final double radius, + final double length, final int segments, + final Color color, final Matrix3x3 rotMatrix, + final double dirX, final double dirY, final double dirZ) { + // Apex is at endPoint (the arrow tip) + // Base center is at endPoint - length * direction (toward arrow start) + final double baseCenterX = endPoint.x - length * dirX; + final double baseCenterY = endPoint.y - length * dirY; + final double baseCenterZ = endPoint.z - length * dirZ; + + // Generate base ring vertices + // In local space, cone points in -Y direction, so base is at Y=0 + final Point3D[] baseRing = new Point3D[segments]; + + for (int i = 0; i < segments; i++) { + final double angle = 2.0 * Math.PI * i / segments; + final double localX = radius * Math.cos(angle); + final double localZ = radius * Math.sin(angle); + + // Base ring vertices at local Y=0 + final Point3D local = new Point3D(localX, 0, localZ); + rotMatrix.transform(local, local); + local.x += baseCenterX; + local.y += baseCenterY; + local.z += baseCenterZ; + baseRing[i] = local; + } + + // Apex point (the arrow tip) + final Point3D apex = new Point3D(endPoint.x, endPoint.y, endPoint.z); + + // Create cone side faces + // Winding: apex → current → next creates CCW winding when viewed from outside + for (int i = 0; i < segments; i++) { + final int next = (i + 1) % segments; + + addShape(new SolidPolygon( + new Point3D(apex.x, apex.y, apex.z), + new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z), + new Point3D(baseRing[next].x, baseRing[next].y, baseRing[next].z), + color)); + } + + // Create base cap of the cone tip (fills the gap between cone and cylinder body) + // Single N-vertex polygon that closes the loop to create segments triangles + // (segments+2 vertices → segments triangles via fan triangulation) + // The base cap faces toward the arrow body/start, opposite to the cone's pointing direction. + // Winding: center → ring[segments-1] → ... → ring[1] → ring[0] → ring[segments-1] + final Point3D baseCenter = new Point3D(baseCenterX, baseCenterY, baseCenterZ); + final Point3D[] tipBaseCapVertices = new Point3D[segments + 2]; + tipBaseCapVertices[0] = baseCenter; + for (int i = 0; i < segments; i++) { + tipBaseCapVertices[i + 1] = baseRing[segments - 1 - i]; + } + tipBaseCapVertices[segments + 1] = baseRing[segments - 1]; // close the loop + addShape(new SolidPolygon(tipBaseCapVertices, color)); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCone.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCone.java new file mode 100644 index 0000000..740b5b9 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCone.java @@ -0,0 +1,268 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.math.Matrix3x3; +import eu.svjatoslav.aukio.e3d.math.Quaternion; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A solid cone that can be oriented in any direction. + * + *

The cone has a circular base and a single apex (tip) point. Two constructors + * are provided for different use cases:

+ * + *
    + *
  • Directional (recommended): Specify apex point and base center point. + * The cone points from apex toward the base center. This allows arbitrary + * orientation and is the most intuitive API.
  • + *
  • Y-axis aligned: Specify base center, radius, and height. The cone + * points in -Y direction (apex at lower Y). Useful for simple vertical cones.
  • + *
+ * + *

Usage examples:

+ *
{@code
+ * // Directional constructor: cone pointing from apex toward base
+ * SolidPolygonCone directionalCone = new SolidPolygonCone(
+ *     new Point3D(0, -100, 0),   // apex (tip of the cone)
+ *     new Point3D(0, 50, 0),     // baseCenter (cone points toward this)
+ *     50,                        // radius of the circular base
+ *     16,                        // segments
+ *     Color.RED
+ * );
+ *
+ * // Y-axis aligned constructor: cone pointing upward
+ * SolidPolygonCone verticalCone = new SolidPolygonCone(
+ *     new Point3D(0, 0, 300),    // baseCenter
+ *     50,                        // radius
+ *     100,                       // height
+ *     16,                        // segments
+ *     Color.RED
+ * );
+ * }
+ * + * @see SolidPolygonCylinder + * @see SolidPolygonArrow + * @see SolidPolygon + */ +public class SolidPolygonCone extends AbstractCompositeShape { + + /** + * Constructs a solid cone pointing from apex toward base center. + * + *

This is the recommended constructor for placing cones in 3D space. + * The cone's apex (tip) is at {@code apexPoint}, and the circular base + * is centered at {@code baseCenterPoint}. The cone points in the direction + * from apex to base center.

+ * + *

Coordinate interpretation:

+ *
    + *
  • {@code apexPoint} - the sharp tip of the cone
  • + *
  • {@code baseCenterPoint} - the center of the circular base; the cone + * "points" in this direction from the apex
  • + *
  • The distance between apex and base center determines the cone height
  • + *
+ * + * @param apexPoint the position of the cone's tip (apex) + * @param baseCenterPoint the center point of the circular base; the cone + * points from apex toward this point + * @param radius the radius of the circular base + * @param segments the number of segments around the circumference. + * Higher values create smoother cones. Minimum is 3. + * @param color the fill color applied to all faces of the cone + */ + public SolidPolygonCone(final Point3D apexPoint, final Point3D baseCenterPoint, + final double radius, final int segments, + final Color color) { + super(); + + // Calculate direction and height from apex to base center + final double dx = baseCenterPoint.x - apexPoint.x; + final double dy = baseCenterPoint.y - apexPoint.y; + final double dz = baseCenterPoint.z - apexPoint.z; + final double height = Math.sqrt(dx * dx + dy * dy + dz * dz); + + // Handle degenerate case: apex and base center are the same point + if (height < 0.001) { + return; + } + + // Normalize direction vector (from apex toward base) + final double nx = dx / height; + final double ny = dy / height; + final double nz = dz / height; + + // Calculate rotation to align Y-axis with direction + // Default cone points in -Y direction (apex at origin, base at -Y) + // We need to rotate from (0, -1, 0) to (nx, ny, nz) + final Quaternion rotation = createRotationFromYAxis(nx, ny, nz); + final Matrix3x3 rotMatrix = rotation.toMatrix(); + + // Generate base ring vertices in local space, then rotate and translate + // In local space: apex is at origin, base is at Y = -height + // (cone points in -Y direction in local space) + final Point3D[] baseRing = new Point3D[segments]; + + for (int i = 0; i < segments; i++) { + final double angle = 2.0 * Math.PI * i / segments; + final double localX = radius * Math.cos(angle); + final double localZ = radius * Math.sin(angle); + + // Base ring vertex in local space (Y = -height) + final Point3D local = new Point3D(localX, -height, localZ); + rotMatrix.transform(local, local); + local.x += apexPoint.x; + local.y += apexPoint.y; + local.z += apexPoint.z; + baseRing[i] = local; + } + + // Apex point (the cone tip) + final Point3D apex = new Point3D(apexPoint.x, apexPoint.y, apexPoint.z); + + // Create side faces connecting each pair of adjacent base vertices to the apex + // Winding: apex → next → current creates CCW winding when viewed from outside + // (Base ring vertices go CCW when viewed from apex looking at base, so we reverse) + for (int i = 0; i < segments; i++) { + final int next = (i + 1) % segments; + + addShape(new SolidPolygon( + new Point3D(apex.x, apex.y, apex.z), + new Point3D(baseRing[next].x, baseRing[next].y, baseRing[next].z), + new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z), + color)); + } + + // Create base cap (circular bottom face) + // Single N-vertex polygon that closes the loop to create segments triangles + // (segments+2 vertices → segments triangles via fan triangulation) + // The cap faces away from the apex (in the direction the cone points). + // Winding: center → ring[0] → ring[1] → ... → ring[segments-1] → ring[0] + final Point3D[] baseCapVertices = new Point3D[segments + 2]; + baseCapVertices[0] = baseCenterPoint; + for (int i = 0; i < segments; i++) { + baseCapVertices[i + 1] = baseRing[i]; + } + baseCapVertices[segments + 1] = baseRing[0]; // close the loop + addShape(new SolidPolygon(baseCapVertices, color)); + + setBackfaceCulling(true); + } + + /** + * Constructs a solid cone with circular base centered at the given point, + * pointing in the -Y direction. + * + *

This constructor creates a Y-axis aligned cone. The apex is positioned + * at {@code baseCenter.y - height} (above the base in the negative Y direction). + * For cones pointing in arbitrary directions, use + * {@link #SolidPolygonCone(Point3D, Point3D, double, int, Color)} instead.

+ * + *

Coordinate system: The cone points in -Y direction (apex at lower Y). + * The base is at Y=baseCenter.y, and the apex is at Y=baseCenter.y - height. + * In Aukio 3D's coordinate system, "up" visually is negative Y.

+ * + * @param baseCenter the center point of the cone's circular base in 3D space + * @param radius the radius of the circular base + * @param height the height of the cone from base center to apex + * @param segments the number of segments around the circumference. + * Higher values create smoother cones. Minimum is 3. + * @param color the fill color applied to all faces of the cone + */ + public SolidPolygonCone(final Point3D baseCenter, final double radius, + final double height, final int segments, + final Color color) { + super(); + + // Apex is above the base (negative Y direction in this coordinate system) + final double apexY = baseCenter.y - height; + final Point3D apex = new Point3D(baseCenter.x, apexY, baseCenter.z); + + // Generate vertices around the circular base + // Vertices are ordered counter-clockwise when viewed from above (from +Y) + final Point3D[] baseRing = new Point3D[segments]; + for (int i = 0; i < segments; i++) { + final double angle = 2.0 * Math.PI * i / segments; + final double x = baseCenter.x + radius * Math.cos(angle); + final double z = baseCenter.z + radius * Math.sin(angle); + baseRing[i] = new Point3D(x, baseCenter.y, z); + } + + // Create side faces connecting each pair of adjacent base vertices to the apex + // Winding: apex → next → current creates CCW winding when viewed from outside + // (Base ring vertices go CCW when viewed from apex looking at base, so we reverse) + for (int i = 0; i < segments; i++) { + final int next = (i + 1) % segments; + + addShape(new SolidPolygon( + new Point3D(apex.x, apex.y, apex.z), + new Point3D(baseRing[next].x, baseRing[next].y, baseRing[next].z), + new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z), + color)); + } + + // Create base cap (circular bottom face) + // Single N-vertex polygon that closes the loop to create segments triangles + // (segments+2 vertices → segments triangles via fan triangulation) + // The base cap faces in +Y direction (downward, away from apex). + // Winding: center → ring[0] → ring[1] → ... → ring[segments-1] → ring[0] + final Point3D[] baseCapVertices = new Point3D[segments + 2]; + baseCapVertices[0] = baseCenter; + for (int i = 0; i < segments; i++) { + baseCapVertices[i + 1] = baseRing[i]; + } + baseCapVertices[segments + 1] = baseRing[0]; // close the loop + addShape(new SolidPolygon(baseCapVertices, color)); + + setBackfaceCulling(true); + } + + /** + * Creates a quaternion that rotates from the -Y axis to the given direction. + * + *

The cone by default points in the -Y direction (apex at origin, base at -Y). + * This method computes the rotation needed to align the cone with the target + * direction vector.

+ * + * @param nx normalized direction X component + * @param ny normalized direction Y component + * @param nz normalized direction Z component + * @return quaternion representing the rotation + */ + private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) { + // Default direction is -Y (0, -1, 0) + // Target direction is (nx, ny, nz) + // Dot product: 0*nx + (-1)*ny + 0*nz = -ny + final double dot = -ny; + + // Check for parallel vectors + if (dot > 0.9999) { + // Direction is nearly -Y, no rotation needed + return Quaternion.identity(); + } + if (dot < -0.9999) { + // Direction is nearly +Y, rotate 180° around X axis + return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI); + } + + // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx) + // This gives the rotation axis + final double axisX = -nz; + final double axisY = 0; + final double axisZ = nx; + final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ); + final double normalizedAxisX = axisX / axisLength; + final double normalizedAxisZ = axisZ / axisLength; + + // Angle from dot product + final double angle = Math.acos(dot); + + return Quaternion.fromAxisAngle( + new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCube.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCube.java new file mode 100755 index 0000000..e55eef6 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCube.java @@ -0,0 +1,45 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; + +/** + * A solid cube centered at a given point with equal side length along all axes. + * This is a convenience subclass of {@link SolidPolygonRectangularBox} that + * constructs a cube from a center point and a half-side length. + * + *

The cube extends {@code size} units in each direction from the center, + * resulting in a total edge length of {@code 2 * size}.

+ * + *

Usage example:

+ *
{@code
+ * SolidPolygonCube cube = new SolidPolygonCube(
+ *         new Point3D(0, 0, 300), 50, Color.GREEN);
+ * shapeCollection.addShape(cube);
+ * }
+ * + * @see SolidPolygonRectangularBox + * @see Color + */ +public class SolidPolygonCube extends SolidPolygonRectangularBox { + + /** + * Constructs a solid cube centered at the given point. + * + * @param center the center point of the cube in 3D space + * @param size the half-side length; the cube extends this distance from + * the center along each axis, giving a total edge length of + * {@code 2 * size} + * @param color the fill color applied to all faces of the cube + */ + public SolidPolygonCube(final Point3D center, final double size, + final Color color) { + super(new Point3D(center.x - size, center.y - size, center.z - size), + new Point3D(center.x + size, center.y + size, center.z + size), + color); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCylinder.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCylinder.java new file mode 100644 index 0000000..3fd7b64 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCylinder.java @@ -0,0 +1,200 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.math.Matrix3x3; +import eu.svjatoslav.aukio.e3d.math.Quaternion; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A solid cylinder defined by two end points. + * + *

The cylinder extends from startPoint to endPoint with circular caps at both + * ends. The number of segments determines the smoothness of the curved surface.

+ * + *

Usage example:

+ *
{@code
+ * // Create a vertical cylinder from Y=100 to Y=200
+ * SolidPolygonCylinder cylinder = new SolidPolygonCylinder(
+ *     new Point3D(0, 100, 0),   // start point (bottom)
+ *     new Point3D(0, 200, 0),   // end point (top)
+ *     10,                        // radius
+ *     16,                        // segments
+ *     Color.RED                  // color
+ * );
+ *
+ * // Create a horizontal cylinder along X axis
+ * SolidPolygonCylinder pipe = new SolidPolygonCylinder(
+ *     new Point3D(-50, 0, 0),
+ *     new Point3D(50, 0, 0),
+ *     5, 12, Color.BLUE
+ * );
+ * }
+ * + * @see SolidPolygonCone + * @see SolidPolygonArrow + * @see SolidPolygon + */ +public class SolidPolygonCylinder extends AbstractCompositeShape { + + /** + * Constructs a solid cylinder between two end points. + * + *

The cylinder has circular caps at both startPoint and endPoint, + * connected by a curved side surface. The orientation is automatically + * calculated from the direction between the two points.

+ * + * @param startPoint the center of the first cap + * @param endPoint the center of the second cap + * @param radius the radius of the cylinder + * @param segments the number of segments around the circumference. + * Higher values create smoother cylinders. Minimum is 3. + * @param color the fill color applied to all polygons + */ + public SolidPolygonCylinder(final Point3D startPoint, final Point3D endPoint, + final double radius, final int segments, + final Color color) { + super(); + + // Calculate direction and distance + final double dx = endPoint.x - startPoint.x; + final double dy = endPoint.y - startPoint.y; + final double dz = endPoint.z - startPoint.z; + final double distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + + // Handle degenerate case: start and end are the same point + if (distance < 0.001) { + return; + } + + // Normalize direction vector + final double nx = dx / distance; + final double ny = dy / distance; + final double nz = dz / distance; + + // Calculate rotation to align Y-axis with direction + // Default cylinder is aligned along Y-axis + // We need to rotate from (0, 1, 0) to (nx, ny, nz) + final Quaternion rotation = createRotationFromYAxis(nx, ny, nz); + final Matrix3x3 rotMatrix = rotation.toMatrix(); + + // Cylinder center is at midpoint between start and end + final double centerX = (startPoint.x + endPoint.x) / 2.0; + final double centerY = (startPoint.y + endPoint.y) / 2.0; + final double centerZ = (startPoint.z + endPoint.z) / 2.0; + final double halfLength = distance / 2.0; + + // Generate ring vertices in local space, then rotate and translate + // In local space: cylinder is aligned along Y-axis + // - startSideRing is at local -Y (toward startPoint) + // - endSideRing is at local +Y (toward endPoint) + final Point3D[] startSideRing = new Point3D[segments]; + final Point3D[] endSideRing = new Point3D[segments]; + + for (int i = 0; i < segments; i++) { + final double angle = 2.0 * Math.PI * i / segments; + final double localX = radius * Math.cos(angle); + final double localZ = radius * Math.sin(angle); + + // Start-side ring (at -halfLength in local Y = toward startPoint) + final Point3D startLocal = new Point3D(localX, -halfLength, localZ); + rotMatrix.transform(startLocal, startLocal); + startLocal.x += centerX; + startLocal.y += centerY; + startLocal.z += centerZ; + startSideRing[i] = startLocal; + + // End-side ring (at +halfLength in local Y = toward endPoint) + final Point3D endLocal = new Point3D(localX, halfLength, localZ); + rotMatrix.transform(endLocal, endLocal); + endLocal.x += centerX; + endLocal.y += centerY; + endLocal.z += centerZ; + endSideRing[i] = endLocal; + } + + // Create side faces (one quad per segment) + // Winding: startSide[i] → endSide[i] → endSide[next] → startSide[next] + // creates CCW winding when viewed from outside the cylinder + for (int i = 0; i < segments; i++) { + final int next = (i + 1) % segments; + + addShape(SolidPolygon.quad( + startSideRing[i], + endSideRing[i], + endSideRing[next], + startSideRing[next], + color)); + } + + // Create start cap (at startPoint, faces outward from cylinder) + // Single N-vertex polygon that closes the loop to create segments triangles + // (segments+2 vertices → segments triangles via fan triangulation) + // Winding: center → ring[0] → ring[1] → ... → ring[segments-1] → ring[0] + final Point3D[] startCapVertices = new Point3D[segments + 2]; + startCapVertices[0] = startPoint; + for (int i = 0; i < segments; i++) { + startCapVertices[i + 1] = startSideRing[i]; + } + startCapVertices[segments + 1] = startSideRing[0]; // close the loop + addShape(new SolidPolygon(startCapVertices, color)); + + // Create end cap (at endPoint, faces outward from cylinder) + // Reverse winding for opposite-facing cap + // Winding: center → ring[segments-1] → ... → ring[1] → ring[0] → ring[segments-1] + final Point3D[] endCapVertices = new Point3D[segments + 2]; + endCapVertices[0] = endPoint; + for (int i = 0; i < segments; i++) { + endCapVertices[i + 1] = endSideRing[segments - 1 - i]; + } + endCapVertices[segments + 1] = endSideRing[segments - 1]; // close the loop + addShape(new SolidPolygon(endCapVertices, color)); + + setBackfaceCulling(true); + } + + /** + * Creates a quaternion that rotates from the +Y axis to the given direction. + * + * @param nx normalized direction X component + * @param ny normalized direction Y component + * @param nz normalized direction Z component + * @return quaternion representing the rotation + */ + private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) { + // Default direction is +Y (0, 1, 0) + // Target direction is (nx, ny, nz) + // Dot product: 0*nx + 1*ny + 0*nz = ny + final double dot = ny; + + // Check for parallel vectors + if (dot > 0.9999) { + // Direction is nearly +Y, no rotation needed + return Quaternion.identity(); + } + if (dot < -0.9999) { + // Direction is nearly -Y, rotate 180° around X axis + return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI); + } + + // Cross product: (0, 1, 0) x (nx, ny, nz) = (nz, 0, -nx) + // This gives the rotation axis + final double axisX = nz; + final double axisY = 0; + final double axisZ = -nx; + final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ); + final double normalizedAxisX = axisX / axisLength; + final double normalizedAxisZ = axisZ / axisLength; + + // Angle from dot product + final double angle = Math.acos(dot); + + return Quaternion.fromAxisAngle( + new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.java new file mode 100644 index 0000000..885f285 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.java @@ -0,0 +1,61 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +import java.util.List; + +/** + * A renderable mesh composed of SolidPolygon triangles. + * + *

This is a generic composite shape that holds a collection of triangles. + * It can be constructed from any source of triangles, such as procedural + * geometry generation or loaded mesh data.

+ * + *

Usage:

+ *
{@code
+ * // From list of triangles
+ * List triangles = ...;
+ * SolidPolygonMesh mesh = new SolidPolygonMesh(triangles, location);
+ *
+ * // With fluent configuration
+ * shapes.addShape(mesh.setShadingEnabled(true).setBackfaceCulling(true));
+ * }
+ * + * @see SolidPolygon the triangle type for rendering + */ +public class SolidPolygonMesh extends AbstractCompositeShape { + + private int triangleCount; + + /** + * Creates a mesh from a list of SolidPolygon triangles. + * + * @param triangles the triangles to include in the mesh + * @param location the position in 3D space + */ + public SolidPolygonMesh(final List triangles, final Point3D location) { + super(location); + this.triangleCount = 0; + + for (final SolidPolygon triangle : triangles) { + addShape(triangle); + triangleCount++; + } + } + + /** + * Returns the number of triangles in this mesh. + * + * @return the triangle count + */ + public int getTriangleCount() { + return triangleCount; + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonPyramid.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonPyramid.java new file mode 100644 index 0000000..90e51d3 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonPyramid.java @@ -0,0 +1,258 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.math.Matrix3x3; +import eu.svjatoslav.aukio.e3d.math.Quaternion; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A solid square-based pyramid that can be oriented in any direction. + * + *

The pyramid has a square base and four triangular faces meeting at an apex + * (tip). Two constructors are provided for different use cases:

+ * + *
    + *
  • Directional (recommended): Specify apex point and base center point. + * The pyramid points from apex toward the base center. This allows arbitrary + * orientation and is the most intuitive API.
  • + *
  • Y-axis aligned: Specify base center, base size, and height. The pyramid + * points in -Y direction (apex at lower Y). Useful for simple vertical pyramids.
  • + *
+ * + *

Usage examples:

+ *
{@code
+ * // Directional constructor: pyramid pointing from apex toward base
+ * SolidPolygonPyramid directionalPyramid = new SolidPolygonPyramid(
+ *     new Point3D(0, -100, 0),   // apex (tip of the pyramid)
+ *     new Point3D(0, 50, 0),     // baseCenter (pyramid points toward this)
+ *     50,                        // baseSize (half-width of square base)
+ *     Color.RED
+ * );
+ *
+ * // Y-axis aligned constructor: pyramid pointing upward
+ * SolidPolygonPyramid verticalPyramid = new SolidPolygonPyramid(
+ *     new Point3D(0, 0, 300),    // baseCenter
+ *     50,                        // baseSize (half-width of square base)
+ *     100,                       // height
+ *     Color.BLUE
+ * );
+ * }
+ * + * @see SolidPolygonCone + * @see SolidPolygonCube + * @see SolidPolygon + */ +public class SolidPolygonPyramid extends AbstractCompositeShape { + + /** + * Constructs a solid square-based pyramid pointing from apex toward base center. + * + *

This is the recommended constructor for placing pyramids in 3D space. + * The pyramid's apex (tip) is at {@code apexPoint}, and the square base + * is centered at {@code baseCenter}. The pyramid points in the direction + * from apex to base center.

+ * + *

Coordinate interpretation:

+ *
    + *
  • {@code apexPoint} - the sharp tip of the pyramid
  • + *
  • {@code baseCenter} - the center of the square base; the pyramid + * "points" in this direction from the apex
  • + *
  • {@code baseSize} - half the width of the square base; the base + * extends this distance from the center along perpendicular axes
  • + *
  • The distance between apex and base center determines the pyramid height
  • + *
+ * + * @param apexPoint the position of the pyramid's tip (apex) + * @param baseCenter the center point of the square base; the pyramid + * points from apex toward this point + * @param baseSize the half-width of the square base; the base extends + * this distance from the center, giving a total base + * edge length of {@code 2 * baseSize} + * @param color the fill color applied to all faces of the pyramid + */ + public SolidPolygonPyramid(final Point3D apexPoint, final Point3D baseCenter, + final double baseSize, final Color color) { + super(); + + // Calculate direction and height from apex to base center + final double dx = baseCenter.x - apexPoint.x; + final double dy = baseCenter.y - apexPoint.y; + final double dz = baseCenter.z - apexPoint.z; + final double height = Math.sqrt(dx * dx + dy * dy + dz * dz); + + // Handle degenerate case: apex and base center are the same point + if (height < 0.001) { + return; + } + + // Normalize direction vector (from apex toward base) + final double nx = dx / height; + final double ny = dy / height; + final double nz = dz / height; + + // Calculate rotation to align Y-axis with direction + // Default pyramid points in -Y direction (apex at origin, base at -Y) + // We need to rotate from (0, -1, 0) to (nx, ny, nz) + final Quaternion rotation = createRotationFromYAxis(nx, ny, nz); + final Matrix3x3 rotMatrix = rotation.toMatrix(); + + // Generate base corner vertices in local space, then rotate and translate + // In local space: apex is at origin, base is at Y = -height + // Base corners form a square centered at (0, -height, 0) + final double h = baseSize; + final Point3D[] baseCorners = new Point3D[4]; + + // Local space corner positions (before rotation) + // Arranged clockwise when viewed from apex (from +Y) + final double[][] localCorners = { + {-h, -height, -h}, // corner 0: negative X, negative Z + {+h, -height, -h}, // corner 1: positive X, negative Z + {+h, -height, +h}, // corner 2: positive X, positive Z + {-h, -height, +h} // corner 3: negative X, positive Z + }; + + for (int i = 0; i < 4; i++) { + final Point3D local = new Point3D(localCorners[i][0], localCorners[i][1], localCorners[i][2]); + rotMatrix.transform(local, local); + local.x += apexPoint.x; + local.y += apexPoint.y; + local.z += apexPoint.z; + baseCorners[i] = local; + } + + // Apex point (the pyramid tip) + final Point3D apex = new Point3D(apexPoint.x, apexPoint.y, apexPoint.z); + + // Create the four triangular faces connecting apex to base edges + // Winding: next → current → apex creates CCW winding when viewed from outside + // (Base corners go CW when viewed from apex, so we reverse to get outward normals) + for (int i = 0; i < 4; i++) { + final int next = (i + 1) % 4; + addShape(new SolidPolygon( + new Point3D(baseCorners[next].x, baseCorners[next].y, baseCorners[next].z), + new Point3D(baseCorners[i].x, baseCorners[i].y, baseCorners[i].z), + new Point3D(apex.x, apex.y, apex.z), + color)); + } + + // Create base cap (square bottom face with center) + // Single N-vertex polygon that closes the loop to create 4 triangles + // (6 vertices → 4 triangles via fan triangulation) + // The cap faces away from the apex (in the direction the pyramid points). + // Winding: center → corner[3] → corner[0] → corner[1] → corner[2] → corner[3] + // (CW when viewed from apex, CCW when viewed from base side) + final Point3D[] baseCapVertices = new Point3D[6]; + baseCapVertices[0] = baseCenter; + baseCapVertices[1] = baseCorners[3]; + baseCapVertices[2] = baseCorners[0]; + baseCapVertices[3] = baseCorners[1]; + baseCapVertices[4] = baseCorners[2]; + baseCapVertices[5] = baseCorners[3]; // close the loop + addShape(new SolidPolygon(baseCapVertices, color)); + + setBackfaceCulling(true); + } + + /** + * Constructs a solid square-based pyramid with base centered at the given point, + * pointing in the -Y direction. + * + *

This constructor creates a Y-axis aligned pyramid. The apex is positioned + * at {@code baseCenter.y - height} (above the base in the negative Y direction). + * For pyramids pointing in arbitrary directions, use + * {@link #SolidPolygonPyramid(Point3D, Point3D, double, Color)} instead.

+ * + *

Coordinate system: The pyramid points in -Y direction (apex at lower Y). + * The base is at Y=baseCenter.y, and the apex is at Y=baseCenter.y - height. + * In Aukio 3D's coordinate system, "up" visually is negative Y.

+ * + * @param baseCenter the center point of the pyramid's base in 3D space + * @param baseSize the half-width of the square base; the base extends + * this distance from the center along X and Z axes, + * giving a total base edge length of {@code 2 * baseSize} + * @param height the height of the pyramid from base center to apex + * @param color the fill color applied to all faces of the pyramid + */ + public SolidPolygonPyramid(final Point3D baseCenter, final double baseSize, + final double height, final Color color) { + super(); + + final double halfBase = baseSize; + final double apexY = baseCenter.y - height; + final double baseY = baseCenter.y; + + // Base corners arranged clockwise when viewed from above (+Y) + // Naming: "negative/positive X" and "negative/positive Z" relative to base center + final Point3D negXnegZ = new Point3D(baseCenter.x - halfBase, baseY, baseCenter.z - halfBase); + final Point3D posXnegZ = new Point3D(baseCenter.x + halfBase, baseY, baseCenter.z - halfBase); + final Point3D posXposZ = new Point3D(baseCenter.x + halfBase, baseY, baseCenter.z + halfBase); + final Point3D negXposZ = new Point3D(baseCenter.x - halfBase, baseY, baseCenter.z + halfBase); + final Point3D apex = new Point3D(baseCenter.x, apexY, baseCenter.z); + + // Four triangular faces from apex to base edges + // Winding: apex → current → next creates CCW when viewed from outside + addShape(new SolidPolygon(negXnegZ, posXnegZ, apex, color)); + addShape(new SolidPolygon(posXnegZ, posXposZ, apex, color)); + addShape(new SolidPolygon(posXposZ, negXposZ, apex, color)); + addShape(new SolidPolygon(negXposZ, negXnegZ, apex, color)); + + // Base cap (square bottom face) + // Single quad using the 4 corner vertices + // Cap faces +Y (downward, away from apex). The base is at higher Y than apex. + // For outward normal (+Y direction), we need CCW ordering when viewed from +Y. + // Quad order: negXposZ → posXposZ → posXnegZ → negXnegZ (CCW from +Y) + addShape(SolidPolygon.quad(negXposZ, posXposZ, posXnegZ, negXnegZ, color)); + + setBackfaceCulling(true); + } + + /** + * Creates a quaternion that rotates from the -Y axis to the given direction. + * + *

The pyramid by default points in the -Y direction (apex at origin, base at -Y). + * This method computes the rotation needed to align the pyramid with the target + * direction vector.

+ * + * @param nx normalized direction X component + * @param ny normalized direction Y component + * @param nz normalized direction Z component + * @return quaternion representing the rotation + */ + private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) { + // Default direction is -Y (0, -1, 0) + // Target direction is (nx, ny, nz) + // Dot product: 0*nx + (-1)*ny + 0*nz = -ny + final double dot = -ny; + + // Check for parallel vectors + if (dot > 0.9999) { + // Direction is nearly -Y, no rotation needed + return Quaternion.identity(); + } + if (dot < -0.9999) { + // Direction is nearly +Y, rotate 180° around X axis + return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI); + } + + // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx) + // This gives the rotation axis + final double axisX = -nz; + final double axisY = 0; + final double axisZ = nx; + final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ); + final double normalizedAxisX = axisX / axisLength; + final double normalizedAxisZ = axisZ / axisLength; + + // Angle from dot product + final double angle = Math.acos(dot); + + return Quaternion.fromAxisAngle( + new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonRectangularBox.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonRectangularBox.java new file mode 100755 index 0000000..38e5856 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonRectangularBox.java @@ -0,0 +1,122 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A solid (filled) rectangular box composed of 6 quadrilateral polygons (1 per face, + * covering all 6 faces). + * + *

The box is defined by two diagonally opposite corner points in 3D space. + * The box is axis-aligned, meaning its edges are parallel to the X, Y, and Z axes.

+ * + *

Vertex layout:

+ *
+ *         cornerB (max) ────────┐
+ *              /│              /│
+ *             / │             / │
+ *            /  │            /  │
+ *           ┌───┼───────────┐   │
+ *           │   │           │   │
+ *           │   │           │   │
+ *           │   └───────────│───┘
+ *           │  /            │  /
+ *           │ /             │ /
+ *           │/              │/
+ *           └───────────────┘ cornerA (min)
+ * 
+ * + *

The eight vertices are derived from the two corner points:

+ *
    + *
  • Corner A defines minimum X, Y, Z
  • + *
  • Corner B defines maximum X, Y, Z
  • + *
  • The other 6 vertices are computed from combinations of these coordinates
  • + *
+ * + *

Usage examples:

+ *
{@code
+ * // Create a box from two opposite corners
+ * SolidPolygonRectangularBox box = new SolidPolygonRectangularBox(
+ *     new Point3D(-50, -25, 100),  // cornerA (minimum X, Y, Z)
+ *     new Point3D(50, 25, 200),    // cornerB (maximum X, Y, Z)
+ *     Color.BLUE
+ * );
+ *
+ * // Create a cube using center + size (see SolidPolygonCube for convenience)
+ * double size = 50;
+ * SolidPolygonRectangularBox cube = new SolidPolygonRectangularBox(
+ *     new Point3D(0 - size, 0 - size, 200 - size),  // cornerA
+ *     new Point3D(0 + size, 0 + size, 200 + size),  // cornerB
+ *     Color.RED
+ * );
+ * }
+ * + * @see SolidPolygonCube + * @see SolidPolygon + */ +public class SolidPolygonRectangularBox extends AbstractCompositeShape { + + /** + * Constructs a solid rectangular box between two diagonally opposite corner + * points in 3D space. + * + *

The box is axis-aligned and fills the rectangular region between the + * two corners. The corner points do not need to be ordered (cornerA can have + * larger coordinates than cornerB); the constructor will determine the actual + * min/max bounds automatically.

+ * + * @param cornerA the first corner point (any of the 8 corners) + * @param cornerB the diagonally opposite corner point + * @param color the fill color applied to all 6 quadrilateral polygons + */ + public SolidPolygonRectangularBox(final Point3D cornerA, final Point3D cornerB, final Color color) { + super(); + + // Determine actual min/max bounds (corners may be in any order) + final double minX = Math.min(cornerA.x, cornerB.x); + final double maxX = Math.max(cornerA.x, cornerB.x); + final double minY = Math.min(cornerA.y, cornerB.y); + final double maxY = Math.max(cornerA.y, cornerB.y); + final double minZ = Math.min(cornerA.z, cornerB.z); + final double maxZ = Math.max(cornerA.z, cornerB.z); + + // Compute all 8 vertices from the bounds + // Naming convention: min/max indicates which bound the coordinate uses + // minMinMin = (minX, minY, minZ), maxMaxMax = (maxX, maxY, maxZ), etc. + final Point3D minMinMin = new Point3D(minX, minY, minZ); + final Point3D maxMinMin = new Point3D(maxX, minY, minZ); + final Point3D maxMinMax = new Point3D(maxX, minY, maxZ); + final Point3D minMinMax = new Point3D(minX, minY, maxZ); + + final Point3D minMaxMin = new Point3D(minX, maxY, minZ); + final Point3D maxMaxMin = new Point3D(maxX, maxY, minZ); + final Point3D minMaxMax = new Point3D(minX, maxY, maxZ); + final Point3D maxMaxMax = new Point3D(maxX, maxY, maxZ); + + // Bottom face (y = minY) - CCW when viewed from below + addShape(new SolidPolygon(new Point3D[]{minMinMin, maxMinMin, maxMinMax, minMinMax}, color)); + + // Top face (y = maxY) - CCW when viewed from above + addShape(new SolidPolygon(new Point3D[]{minMaxMin, minMaxMax, maxMaxMax, maxMaxMin}, color)); + + // Front face (z = minZ) - CCW when viewed from front + addShape(new SolidPolygon(new Point3D[]{minMinMin, minMaxMin, maxMaxMin, maxMinMin}, color)); + + // Back face (z = maxZ) - CCW when viewed from behind + addShape(new SolidPolygon(new Point3D[]{maxMinMax, maxMaxMax, minMaxMax, minMinMax}, color)); + + // Left face (x = minX) - CCW when viewed from left + addShape(new SolidPolygon(new Point3D[]{minMinMin, minMinMax, minMaxMax, minMaxMin}, color)); + + // Right face (x = maxX) - CCW when viewed from right + addShape(new SolidPolygon(new Point3D[]{maxMinMin, maxMaxMin, maxMaxMax, maxMinMax}, color)); + + setBackfaceCulling(true); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonSphere.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonSphere.java new file mode 100644 index 0000000..7ebb0cb --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonSphere.java @@ -0,0 +1,84 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A solid sphere composed of triangular polygons. + * + *

The sphere is constructed using a latitude-longitude grid (UV sphere). + * The number of segments determines the smoothness - more segments create + * a smoother sphere but require more polygons.

+ * + *

Usage example:

+ *
{@code
+ * // Create a sphere with radius 50 and 16 segments (smooth)
+ * SolidPolygonSphere sphere = new SolidPolygonSphere(
+ *     new Point3D(0, 0, 200), 50, 16, Color.RED);
+ * shapeCollection.addShape(sphere);
+ * }
+ * + * @see SolidPolygonCube + * @see SolidPolygon + * @see AbstractCompositeShape + */ +public class SolidPolygonSphere extends AbstractCompositeShape { + + /** + * Constructs a solid sphere centered at the given point. + * + * @param center the center point of the sphere in 3D space + * @param radius the radius of the sphere + * @param segments the number of segments (latitude/longitude divisions). + * Higher values create smoother spheres. Minimum is 3. + * @param color the fill color applied to all triangular polygons + */ + public SolidPolygonSphere(final Point3D center, final double radius, + final int segments, final Color color) { + super(); + + final int rings = segments; + final int sectors = segments * 2; + + for (int i = 0; i < rings; i++) { + double lat0 = Math.PI * (-0.5 + (double) i / rings); + double lat1 = Math.PI * (-0.5 + (double) (i + 1) / rings); + + for (int j = 0; j < sectors; j++) { + double lon0 = 2 * Math.PI * (double) j / sectors; + double lon1 = 2 * Math.PI * (double) (j + 1) / sectors; + + Point3D p0 = sphericalToCartesian(center, radius, lat0, lon0); + Point3D p1 = sphericalToCartesian(center, radius, lat0, lon1); + Point3D p2 = sphericalToCartesian(center, radius, lat1, lon0); + Point3D p3 = sphericalToCartesian(center, radius, lat1, lon1); + + if (i > 0) { + addShape(new SolidPolygon(p0, p2, p1, color)); + } + + if (i < rings - 1) { + addShape(new SolidPolygon(p2, p3, p1, color)); + } + } + } + + setBackfaceCulling(true); + } + + private Point3D sphericalToCartesian(final Point3D center, + final double radius, + final double lat, + final double lon) { + double x = center.x + radius * Math.cos(lat) * Math.cos(lon); + double y = center.y + radius * Math.sin(lat); + double z = center.z + radius * Math.cos(lat) * Math.sin(lon); + return new Point3D(x, y, z); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/package-info.java new file mode 100644 index 0000000..0d33bac --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/package-info.java @@ -0,0 +1,24 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Solid composite shapes built from SolidTriangle primitives. + * + *

These shapes render as filled surfaces with optional flat shading. + * Useful for creating opaque 3D objects like boxes, spheres, and cylinders.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCube} - A solid cube
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonRectangularBox} - A solid box
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonSphere} - A solid sphere
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCylinder} - A solid cylinder
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonPyramid} - A solid pyramid
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCube + */ + +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/SdfGlyphCache.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/SdfGlyphCache.java new file mode 100644 index 0000000..7090806 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/SdfGlyphCache.java @@ -0,0 +1,278 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas; + +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas.FONT_CHAR_HEIGHT_TEXTURE_PIXELS; +import static eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas.FONT_CHAR_WIDTH_TEXTURE_PIXELS; + +/** + * Per-glyph signed distance field (SDF) cache for sharp text rendering. + * + *

Instead of storing ink coverage (a photocopy of the glyph that blurs + * under any resampling), a distance field stores, per texel, the distance + * to the nearest glyph edge — negative inside the ink, positive outside. + * The rasterizer re-derives the edge per screen pixel from this smooth + * field, so text stays sharp at any magnification and degrades to clean + * gray under minification instead of aliasing.

+ * + *

Each glyph is rendered at {@link #SUPERSAMPLE}x the cell resolution + * with anti-aliasing (giving the contour sub-texel precision), converted + * to a signed distance field by an exact Euclidean distance transform + * (Felzenszwalb & Huttenlocher separable EDT), clamped to + * {@link #SPREAD_TEXELS} texels of gradient around the edge, and + * downsampled by averaging to the cell size. Results are cached per + * character; stamping a glyph into a canvas mask is a small block copy.

+ * + *

Mask values: 0 = deep inside ink, 127/128 = the edge, 255 = far + * outside any glyph. The matching coverage formula lives in + * {@code TexturedTriangle}'s SDF scanline path.

+ * + * @see TextCanvas + */ +public final class SdfGlyphCache { + + /** + * Width of the distance gradient around the glyph edge, in + * primary-texture texels. The normalized mask value 0.5 +/- 0.5 spans + * -SPREAD..+SPREAD texels of signed distance. + */ + public static final double SPREAD_TEXELS = 2.0; + + /** + * Glyph rasterization supersampling factor relative to the cell size. + */ + private static final int SUPERSAMPLE = 4; + + private static final int GLYPH_W = FONT_CHAR_WIDTH_TEXTURE_PIXELS; + private static final int GLYPH_H = FONT_CHAR_HEIGHT_TEXTURE_PIXELS; + private static final int HI_W = GLYPH_W * SUPERSAMPLE; + private static final int HI_H = GLYPH_H * SUPERSAMPLE; + + /** + * Signed distance clamp range in hi-res pixels. + */ + private static final float SPREAD_HI = (float) (SPREAD_TEXELS * SUPERSAMPLE); + + /** + * Hi-res scratch font, sized to fit the scratch (see static init). + * Liberation Mono Bold is metric-compatible with Courier New (same + * 0.6em advance, so the cell grid is unchanged) but sans-serif with + * uniform sturdy strokes — Courier's serifs and thin strokes decay + * into unresolvable noise when the distance field is minified. Falls + * back to the logical Monospaced family. + */ + private static final Font FONT_HI_SIZED; + + /** + * Baseline offset in the hi-res scratch, chosen from the font's + * actual metrics so the tallest glyph fits vertically centered. + */ + private static final int BASELINE_HI; + + static { + Font family = new Font("Liberation Mono", Font.BOLD, 12); + if (!family.getFamily().toLowerCase().contains("liberation")) { + family = new Font("Monospaced", Font.BOLD, 12); + } + + // Size the font so its metrics fit the scratch with margin: + // advance <= HI_W (wide glyphs must not clip the cell edge, which + // would corrupt the distance field there) and ascent+descent <= + // 95% of HI_H. + final BufferedImage probe = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + final Graphics2D pg = probe.createGraphics(); + int size = (int) (FONT_CHAR_HEIGHT_TEXTURE_PIXELS / 1.066) * SUPERSAMPLE; + int baseline = HI_H * 3 / 4; + while (size > 8) { + final Font f = family.deriveFont((float) size); + pg.setFont(f); + final java.awt.font.FontRenderContext frc = pg.getFontRenderContext(); + double maxAdvance = 0; + for (char c = 33; c < 127; c++) { + maxAdvance = Math.max(maxAdvance, + f.getStringBounds(new char[]{c}, 0, 1, frc).getWidth()); + } + final int ascent = pg.getFontMetrics().getMaxAscent(); + final int descent = pg.getFontMetrics().getMaxDescent(); + if (maxAdvance <= HI_W * 0.98 && ascent + descent <= HI_H * 0.95) { + baseline = (HI_H + ascent - descent) / 2; + break; + } + size--; + } + pg.dispose(); + BASELINE_HI = baseline; + FONT_HI_SIZED = family.deriveFont((float) size); + } + + private static final float INF = 1e15f; + + private static final Map CACHE = new ConcurrentHashMap<>(); + + /** + * Serializes glyph rasterization: ConcurrentHashMap.computeIfAbsent + * locks per bin, so two threads generating DIFFERENT glyphs could + * otherwise enter generate() concurrently and use the shared static + * FONT_HI_SIZED at the same time. libfreetype does not tolerate + * concurrent scaler access — observed as a SIGSEGV in + * FreetypeFontScaler.getGlyphMetricsNative on the pty-reader thread + * (aukio terminal panel) racing another text canvas. + */ + private static final Object FONT_RENDER_LOCK = new Object(); + + private SdfGlyphCache() { + } + + /** + * Returns the cached {@link #GLYPH_W} x {@link #GLYPH_H} distance + * field for the given character, generating it on first access. + * Values 0 (inside ink) .. 255 (far outside), edge at ~127.5. + * + * @param c the character + * @return the glyph mask (row-major, one int per texel, do not modify) + */ + public static int[] glyphMask(final char c) { + return CACHE.computeIfAbsent(c, SdfGlyphCache::generate); + } + + private static int[] generate(final char c) { + final int[] px; + synchronized (FONT_RENDER_LOCK) { + final BufferedImage scratch = new BufferedImage(HI_W, HI_H, BufferedImage.TYPE_INT_RGB); + final Graphics2D g = scratch.createGraphics(); + g.setColor(java.awt.Color.BLACK); + g.fillRect(0, 0, HI_W, HI_H); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g.setFont(FONT_HI_SIZED); + g.setColor(java.awt.Color.WHITE); + g.drawChars(new char[]{c}, 0, 1, 0, BASELINE_HI); + g.dispose(); + px = ((DataBufferInt) scratch.getRaster().getDataBuffer()).getData(); + } + + final boolean[] ink = new boolean[HI_W * HI_H]; + for (int i = 0; i < px.length; i++) { + ink[i] = (px[i] & 0xff) > 127; + } + + // Distance to nearest ink pixel (meaningful outside the glyph) + // and to nearest background pixel (meaningful inside it). + final float[] dOut = edt(ink, HI_W, HI_H); + final boolean[] background = new boolean[ink.length]; + for (int i = 0; i < ink.length; i++) { + background[i] = !ink[i]; + } + final float[] dIn = edt(background, HI_W, HI_H); + + // Signed distance, clamped to the spread and averaged down to + // cell resolution. Averaging the FIELD (not coverage) preserves + // the edge position under the downsample. + final int[] mask = new int[GLYPH_W * GLYPH_H]; + for (int y = 0; y < GLYPH_H; y++) { + for (int x = 0; x < GLYPH_W; x++) { + float sum = 0; + for (int sy = 0; sy < SUPERSAMPLE; sy++) { + final int rowBase = (y * SUPERSAMPLE + sy) * HI_W; + for (int sx = 0; sx < SUPERSAMPLE; sx++) { + final int i = rowBase + x * SUPERSAMPLE + sx; + float signed = dOut[i] - dIn[i]; + if (signed > SPREAD_HI) signed = SPREAD_HI; + else if (signed < -SPREAD_HI) signed = -SPREAD_HI; + sum += signed; + } + } + final float avg = sum / (SUPERSAMPLE * SUPERSAMPLE); + final int v = Math.round((avg / SPREAD_HI * 0.5f + 0.5f) * 255f); + mask[y * GLYPH_W + x] = 0xFF000000 | (v << 16) | (v << 8) | v; + } + } + return mask; + } + + /** + * Exact squared Euclidean distance transform of the given feature + * mask via two separable 1-D passes (Felzenszwalb & Huttenlocher). + * + * @param feature true at feature (zero-distance) pixels + * @param w grid width + * @param h grid height + * @return per-pixel distance to the nearest feature pixel + */ + private static float[] edt(final boolean[] feature, final int w, final int h) { + final float[] f = new float[w * h]; + for (int i = 0; i < f.length; i++) { + f[i] = feature[i] ? 0f : INF; + } + + final int maxDim = Math.max(w, h); + final int[] v = new int[maxDim]; + final float[] z = new float[maxDim + 1]; + final float[] colIn = new float[maxDim]; + final float[] colOut = new float[maxDim]; + + final float[] d = new float[w * h]; + for (int x = 0; x < w; x++) { + for (int y = 0; y < h; y++) { + colIn[y] = f[y * w + x]; + } + edt1d(colIn, colOut, h, v, z); + for (int y = 0; y < h; y++) { + d[y * w + x] = colOut[y]; + } + } + for (int y = 0; y < h; y++) { + System.arraycopy(d, y * w, colIn, 0, w); + edt1d(colIn, colOut, w, v, z); + System.arraycopy(colOut, 0, d, y * w, w); + } + + for (int i = 0; i < d.length; i++) { + d[i] = (float) Math.sqrt(d[i]); + } + return d; + } + + /** + * 1-D squared distance transform: d[q] = min over p of + * (q-p)^2 + f[p], via the lower envelope of parabolas. + */ + private static void edt1d(final float[] f, final float[] d, final int n, + final int[] v, final float[] z) { + int k = 0; + v[0] = 0; + z[0] = Float.NEGATIVE_INFINITY; + z[1] = Float.POSITIVE_INFINITY; + for (int q = 1; q < n; q++) { + float s = ((f[q] + (float) q * q) - (f[v[k]] + (float) v[k] * v[k])) + / (2f * q - 2f * v[k]); + while (s <= z[k]) { + k--; + s = ((f[q] + (float) q * q) - (f[v[k]] + (float) v[k] * v[k])) + / (2f * q - 2f * v[k]); + } + k++; + v[k] = q; + z[k] = s; + z[k + 1] = Float.POSITIVE_INFINITY; + } + k = 0; + for (int q = 0; q < n; q++) { + while (z[k + 1] < q) { + k++; + } + final float dv = q - v[k]; + d[q] = dv * dv + f[v[k]]; + } + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/TextCanvas.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/TextCanvas.java new file mode 100644 index 0000000..fdfe6c7 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/TextCanvas.java @@ -0,0 +1,363 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas; + +import eu.svjatoslav.aukio.e3d.gui.TextPointer; +import eu.svjatoslav.aukio.e3d.math.Transform; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.TexturedRectangle; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.TextureBitmap; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; + +import static eu.svjatoslav.aukio.e3d.renderer.raster.Color.BLACK; +import static eu.svjatoslav.aukio.e3d.renderer.raster.Color.WHITE; + +/** + * A text rendering surface in 3D space that displays a grid of characters. + * + *

{@code TextCanvas} extends {@link TexturedRectangle} and renders a 2D grid of + * characters (rows and columns) onto a texture-mapped rectangle. Each character cell + * supports independent foreground and background colors.

+ * + *

Characters are rendered using a monospace font at a fixed cell size + * ({@value #FONT_CHAR_WIDTH_TEXTURE_PIXELS} x {@value #FONT_CHAR_HEIGHT_TEXTURE_PIXELS} + * texture pixels per character). The texture is a signed distance field (SDF): + * the mask stores per-texel distance to the nearest glyph edge (generated once + * per character by {@link SdfGlyphCache} and stamped per cell), while the + * primary bitmap and a separate foreground layer carry the cell colors. The + * rasterizer re-derives glyph edges per screen pixel from the distance field, + * so text stays sharp at any zoom and any angle from a single render path.

+ * + *

Usage example

+ *
{@code
+ * Transform location = new Transform(new Point3D(0, 0, 500));
+ * TextCanvas canvas = new TextCanvas(location, "Hello, World!",
+ *         Color.WHITE, Color.BLACK);
+ * shapeCollection.addShape(canvas);
+ *
+ * // Or create a blank canvas and write to it
+ * TextCanvas blank = new TextCanvas(location, new TextPointer(10, 40),
+ *         Color.GREEN, Color.BLACK);
+ * blank.locate(0, 0);
+ * blank.print("Line 1");
+ * blank.locate(1, 0);
+ * blank.print("Line 2");
+ * }
+ * + * @see SdfGlyphCache + * @see TexturedRectangle + */ +public class TextCanvas extends TexturedRectangle { + + /** + * Font character width in world coordinates. + */ + public static final int FONT_CHAR_WIDTH = 8; + + /** + * Font character height in world coordinates. + */ + public static final int FONT_CHAR_HEIGHT = 16; + + /** + * Font character width in texture pixels. + */ + public static final int FONT_CHAR_WIDTH_TEXTURE_PIXELS = 16; + + /** + * Font character height in texture pixels. + */ + public static final int FONT_CHAR_HEIGHT_TEXTURE_PIXELS = 32; + + private final TextPointer size; + private final TextPointer cursorLocation = new TextPointer(); + private Color backgroundColor = BLACK; + private Color foregroundColor = WHITE; + + /** + * Creates a text canvas initialized with the given text string. + * + *

The canvas dimensions are automatically computed from the text content + * (number of lines determines rows, the longest line determines columns).

+ * + * @param location the 3D transform positioning this canvas in the scene + * @param text the initial text content (may contain newlines for multiple rows) + * @param foregroundColor the default text color + * @param backgroundColor the default background color + */ + public TextCanvas(final Transform location, final String text, + final Color foregroundColor, final Color backgroundColor) { + this(location, getTextDimensions(text), foregroundColor, + backgroundColor); + setText(text); + } + + /** + * Creates a blank text canvas with the specified dimensions. + * + *

The canvas is initialized with spaces in every cell, filled with the + * specified background color. Characters can be written using + * {@link #putChar(char)}, {@link #print(String)}, or {@link #setText(String)}.

+ * + * @param dimensions the grid size as a {@link TextPointer} where + * {@code row} is the number of rows and {@code column} is the number of columns + * @param location the 3D transform positioning this canvas in the scene + * @param foregroundColor the default text color + * @param backgroundColor the default background color + */ + public TextCanvas(final Transform location, final TextPointer dimensions, + final Color foregroundColor, final Color backgroundColor) { + super(location); + + size = dimensions; + final int columns = dimensions.column; + final int rows = dimensions.row; + + this.backgroundColor = backgroundColor; + this.foregroundColor = foregroundColor; + + // initialize underlying textured rectangle + initialize( + columns * FONT_CHAR_WIDTH, + rows * FONT_CHAR_HEIGHT, + columns * FONT_CHAR_WIDTH_TEXTURE_PIXELS, + rows * FONT_CHAR_HEIGHT_TEXTURE_PIXELS, + 0); + + // SDF layers: the distance mask carries glyph SHAPES (bilinear + // sampled, footprint-scaled coverage at render time), the primary + // bitmap is the background color layer and sdfForeground the + // hard ink color layer. + getTexture().primaryBitmap.fillColor(backgroundColor); + + final Texture texture = getTexture(); + texture.sdfMask = new TextureBitmap( + columns * FONT_CHAR_WIDTH_TEXTURE_PIXELS, + rows * FONT_CHAR_HEIGHT_TEXTURE_PIXELS, 1); + texture.sdfMask.fillColor(WHITE); // 255 = far outside any glyph + texture.sdfForeground = new TextureBitmap( + columns * FONT_CHAR_WIDTH_TEXTURE_PIXELS, + rows * FONT_CHAR_HEIGHT_TEXTURE_PIXELS, 1); + texture.sdfForeground.fillColor(foregroundColor); + texture.sdfSpreadTexels = SdfGlyphCache.SPREAD_TEXELS; + } + + /** + * Computes the row and column dimensions needed to fit the given text. + * + * @param text the text content (may contain newlines) + * @return a {@link TextPointer} where {@code row} is the number of lines and + * {@code column} is the length of the longest line + */ + public static TextPointer getTextDimensions(final String text) { + + final BufferedReader reader = new BufferedReader(new StringReader(text)); + + int rows = 0; + int columns = 0; + + while (true) { + final String line; + try { + line = reader.readLine(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + if (line == null) + return new TextPointer(rows, columns); + + rows++; + columns = Math.max(columns, line.length()); + } + } + + /** + * Clears the entire canvas, resetting all characters to spaces with the default colors. + * + *

The SDF mask and both color layers are reset.

+ */ + public void clear() { + getTexture().primaryBitmap.fillColor(backgroundColor); + getTexture().sdfMask.fillColor(WHITE); + getTexture().sdfForeground.fillColor(foregroundColor); + } + + private void drawCharToTexture(final int row, final int column, + final char character, final Color foreground) { + final Texture texture = getTexture(); + final int px = column * FONT_CHAR_WIDTH_TEXTURE_PIXELS; + final int py = row * FONT_CHAR_HEIGHT_TEXTURE_PIXELS; + + // Background and ink color layers: hard per-cell fills (sampled + // nearest; only where the SDF coverage selects them). + texture.primaryBitmap.drawRectangle(px, py, + px + FONT_CHAR_WIDTH_TEXTURE_PIXELS, + py + FONT_CHAR_HEIGHT_TEXTURE_PIXELS, backgroundColor); + texture.sdfForeground.drawRectangle(px, py, + px + FONT_CHAR_WIDTH_TEXTURE_PIXELS, + py + FONT_CHAR_HEIGHT_TEXTURE_PIXELS, foreground); + + // Stamp the cached glyph distance field into the mask. + final int[] glyph = SdfGlyphCache.glyphMask(character); + final int[] dst = texture.sdfMask.pixels; + final int maskWidth = texture.sdfMask.width; + for (int gy = 0; gy < FONT_CHAR_HEIGHT_TEXTURE_PIXELS; gy++) { + System.arraycopy(glyph, gy * FONT_CHAR_WIDTH_TEXTURE_PIXELS, + dst, (py + gy) * maskWidth + px, FONT_CHAR_WIDTH_TEXTURE_PIXELS); + } + } + + /** + * Returns the dimensions of this text canvas. + * + * @return a {@link TextPointer} where {@code row} is the number of rows + * and {@code column} is the number of columns + */ + public TextPointer getSize() { + return size; + } + + /** + * Moves the internal cursor to the specified row and column. + * + *

Subsequent calls to {@link #putChar(char)} and {@link #print(String)} will + * begin writing at this position.

+ * + * @param row the target row (0-based) + * @param column the target column (0-based) + */ + public void locate(final int row, final int column) { + cursorLocation.row = row; + cursorLocation.column = column; + } + + /** + * Prints a string starting at the current cursor location, advancing the cursor after each character. + * + *

When the cursor reaches the end of a row, it wraps to the beginning of the next row.

+ * + * @param text the text to print + * @see #locate(int, int) + */ + public void print(final String text) { + for (int i = 0; i < text.length(); i++) + putChar(text.charAt(i)); + } + + /** + * Writes a character at the current cursor location and advances the cursor. + * + *

The cursor moves one column to the right. If it exceeds the row width, + * it wraps to column 0 of the next row.

+ * + * @param character the character to write + */ + public void putChar(final char character) { + putChar(cursorLocation, character); + + cursorLocation.column++; + if (cursorLocation.column >= size.column) { + cursorLocation.column = 0; + cursorLocation.row++; + } + } + + /** + * Writes a character at the specified row and column using the current foreground and background colors. + * + *

If the row or column is out of bounds, the call is silently ignored.

+ * + * @param row the row index (0-based) + * @param column the column index (0-based) + * @param character the character to write + */ + public void putChar(final int row, final int column, final char character) { + if (row < 0 || row >= size.row || column < 0 || column >= size.column) + return; + + drawCharToTexture(row, column, character, foregroundColor); + } + + /** + * Writes a character at the position specified by a {@link TextPointer}. + * + * @param location the row and column position + * @param character the character to write + */ + public void putChar(final TextPointer location, final char character) { + putChar(location.row, location.column, character); + } + + /** + * Sets the default background color for subsequent character writes. + * + * @param backgroundColor the new background color + */ + public void setBackgroundColor( + final eu.svjatoslav.aukio.e3d.renderer.raster.Color backgroundColor) { + this.backgroundColor = backgroundColor; + } + + /** + * Sets the default foreground (text) color for subsequent character writes. + * + * @param foregroundColor the new foreground color + */ + public void setForegroundColor( + final eu.svjatoslav.aukio.e3d.renderer.raster.Color foregroundColor) { + this.foregroundColor = foregroundColor; + } + + /** + * Replaces the entire canvas content with the given multi-line text string. + * + *

Each line of text (separated by newlines) is written to consecutive rows, + * starting from row 0. Characters beyond the canvas width are ignored.

+ * + * @param text the text to display (may contain newline characters) + */ + public void setText(final String text) { + final BufferedReader reader = new BufferedReader(new StringReader(text)); + + int row = 0; + + while (true) { + final String line; + try { + line = reader.readLine(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + if (line == null) + return; + + int column = 0; + for (int i = 0; i < line.length(); i++) { + putChar(row, column, line.charAt(i)); + column++; + } + row++; + } + } + + /** + * Sets the foreground color of the whole canvas. + * + *

Fills the SDF ink color layer; cell shapes are untouched, so + * text content and background colors are preserved.

+ * + * @param color the new foreground color + */ + public void setTextColor(final Color color) { + getTexture().sdfForeground.fillColor(color); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/package-info.java new file mode 100644 index 0000000..1e8d0f3 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/package-info.java @@ -0,0 +1,9 @@ +/** + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + *

+ * + * Text canvas is a 2D canvas that can be used to render text. + */ + +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/Grid2D.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/Grid2D.java new file mode 100644 index 0000000..8f46cc3 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/Grid2D.java @@ -0,0 +1,80 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.geometry.Rectangle; +import eu.svjatoslav.aukio.e3d.math.Transform; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A 2D grid of line segments lying in the XY plane (Z = 0 in local space). + * The grid is divided into configurable numbers of cells along the X and Y axes, + * producing a regular rectangular mesh of lines. + * + *

This shape is useful for rendering floors, walls, reference planes, or any + * flat surface that needs a grid overlay. The grid is positioned and oriented + * in world space using a {@link Transform}.

+ * + *

Usage example:

+ *
{@code
+ * Transform transform = new Transform(new Point3D(0, 100, 0));
+ * Rectangle rect = new Rectangle(new Point2D(-500, -500), new Point2D(500, 500));
+ * LineAppearance appearance = new LineAppearance(1, Color.GRAY);
+ * Grid2D grid = new Grid2D(transform, rect, 10, 10, appearance);
+ * shapeCollection.addShape(grid);
+ * }
+ * + * @see Grid3D + * @see LineAppearance + * @see AbstractCompositeShape + */ +public class Grid2D extends AbstractCompositeShape { + + /** + * Constructs a 2D grid in the XY plane with the specified dimensions and + * number of divisions. + * + * @param transform the transform defining the grid's position and orientation + * in world space + * @param rectangle the rectangular dimensions of the grid in local XY space + * @param xDivisionCount the number of divisions (cells) along the X axis; + * produces {@code xDivisionCount + 1} vertical lines + * @param yDivisionCount the number of divisions (cells) along the Y axis; + * produces {@code yDivisionCount + 1} horizontal lines + * @param appearance the line appearance (color, width) used for all grid lines + */ + public Grid2D(final Transform transform, final Rectangle rectangle, + final int xDivisionCount, final int yDivisionCount, + final LineAppearance appearance) { + + super(transform); + + final double stepY = rectangle.getHeight() / yDivisionCount; + final double stepX = rectangle.getWidth() / xDivisionCount; + + for (int ySlice = 0; ySlice <= yDivisionCount; ySlice++) { + final double y = (ySlice * stepY) + rectangle.getLowerY(); + + for (int xSlice = 0; xSlice <= xDivisionCount; xSlice++) { + final double x = (xSlice * stepX) + rectangle.getLowerX(); + + final Point3D p1 = new Point3D(x, y, 0); + final Point3D p2 = new Point3D(x + stepX, y, 0); + final Point3D p3 = new Point3D(x, y + stepY, 0); + + if (xSlice < xDivisionCount) + addShape(appearance.getLine(p1, p2)); + + if (ySlice < yDivisionCount) + addShape(appearance.getLine(p1, p3)); + } + + } + + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/Grid3D.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/Grid3D.java new file mode 100755 index 0000000..5adff7b --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/Grid3D.java @@ -0,0 +1,87 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A 3D grid of line segments filling a rectangular volume defined by two + * diagonally opposite corner points. Lines run along all three axes (X, Y, and Z) + * at regular intervals determined by the step size. + * + *

At each grid intersection point, up to three line segments are created + * (one along each axis), forming a three-dimensional lattice.

+ * + *

This shape is useful for visualizing 3D space, voxel boundaries, or + * spatial reference grids in a scene.

+ * + *

Usage example:

+ *
{@code
+ * LineAppearance appearance = new LineAppearance(1, Color.GRAY);
+ * Point3D cornerA = new Point3D(-100, -100, -100);
+ * Point3D cornerB = new Point3D(100, 100, 100);
+ * Grid3D grid = new Grid3D(cornerA, cornerB, 50, appearance);
+ * shapeCollection.addShape(grid);
+ * }
+ * + * @see Grid2D + * @see LineAppearance + * @see AbstractCompositeShape + */ +public class Grid3D extends AbstractCompositeShape { + + /** + * Constructs a 3D grid filling the volume between two diagonally opposite + * corner points. + * + *

The corner points do not need to be in any particular min/max order; + * the constructor automatically normalizes them so that grid generation + * always proceeds from minimum to maximum coordinates.

+ * + * @param cornerA the first corner point defining the volume + * @param cornerB the diagonally opposite corner point + * @param step the spacing between grid lines along each axis; must be positive + * @param appearance the line appearance (color, width) used for all grid lines + */ + public Grid3D(final Point3D cornerA, final Point3D cornerB, final double step, + final LineAppearance appearance) { + + super(); + + // Determine actual min/max bounds (corners may be in any order) + final double minX = Math.min(cornerA.x, cornerB.x); + final double maxX = Math.max(cornerA.x, cornerB.x); + final double minY = Math.min(cornerA.y, cornerB.y); + final double maxY = Math.max(cornerA.y, cornerB.y); + final double minZ = Math.min(cornerA.z, cornerB.z); + final double maxZ = Math.max(cornerA.z, cornerB.z); + + for (double x = minX; x <= maxX; x += step) { + for (double y = minY; y <= maxY; y += step) { + for (double z = minZ; z <= maxZ; z += step) { + + final Point3D p = new Point3D(x, y, z); + + // Line along X axis + if ((x + step) <= maxX) { + addShape(appearance.getLine(p, new Point3D(x + step, y, z))); + } + + // Line along Y axis + if ((y + step) <= maxY) { + addShape(appearance.getLine(p, new Point3D(x, y + step, z))); + } + + // Line along Z axis + if ((z + step) <= maxZ) { + addShape(appearance.getLine(p, new Point3D(x, y, z + step))); + } + } + } + } + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeArrow.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeArrow.java new file mode 100644 index 0000000..0d4cca5 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeArrow.java @@ -0,0 +1,321 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.math.Matrix3x3; +import eu.svjatoslav.aukio.e3d.math.Quaternion; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A 3D wireframe arrow shape composed of a cylindrical body and a conical tip. + * + *

The arrow points from a start point to an end point, with the tip + * located at the end point. The wireframe consists of:

+ *
    + *
  • Body: Two circular rings connected by lines between corresponding vertices
  • + *
  • Tip: A circular ring at the cone base with lines to the apex
  • + *
+ * + *

Usage example:

+ *
{@code
+ * // Create a red arrow pointing from origin to (100, -50, 200)
+ * LineAppearance appearance = new LineAppearance(2, Color.RED);
+ * WireframeArrow arrow = new WireframeArrow(
+ *     new Point3D(0, 0, 0),      // start point
+ *     new Point3D(100, -50, 200), // end point
+ *     8,                         // body radius
+ *     20,                        // tip radius
+ *     40,                        // tip length
+ *     16,                        // segments
+ *     appearance
+ * );
+ * shapeCollection.addShape(arrow);
+ * }
+ * + * @see WireframeCone + * @see WireframeCylinder + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonArrow + */ +public class WireframeArrow extends AbstractCompositeShape { + +/** + * Default number of segments for arrow smoothness. + */ +private static final int DEFAULT_SEGMENTS = 12; + +/** + * Default tip radius as a fraction of body radius (2.5x). + */ +private static final double TIP_RADIUS_FACTOR = 2.5; + +/** + * Default tip length as a fraction of body radius (5.0x). + */ +private static final double TIP_LENGTH_FACTOR = 5.0; + +/** + * Constructs a 3D wireframe arrow pointing from start to end with sensible defaults. + * + *

This simplified constructor automatically calculates the tip radius as + * 2.5 times the body radius, the tip length as 5 times the body radius, and + * uses 12 segments for smoothness. For custom tip dimensions or segment count, + * use the full constructor.

+ * + * @param startPoint the origin point of the arrow (where the body starts) + * @param endPoint the destination point of the arrow (where the tip points to) + * @param bodyRadius the radius of the cylindrical body; tip dimensions are + * calculated automatically from this value + * @param appearance the line appearance (color, width) used for all lines + */ +public WireframeArrow(final Point3D startPoint, final Point3D endPoint, + final double bodyRadius, final LineAppearance appearance) { + this(startPoint, endPoint, bodyRadius, + bodyRadius * TIP_RADIUS_FACTOR, + bodyRadius * TIP_LENGTH_FACTOR, + DEFAULT_SEGMENTS, appearance); +} + +/** + * Constructs a 3D wireframe arrow pointing from start to end with full control over all dimensions. + * + *

The arrow consists of a cylindrical body extending from the start point + * towards the end, and a conical tip at the end point. If the distance between + * start and end is less than or equal to the tip length, only the cone tip + * is rendered.

+ * + * @param startPoint the origin point of the arrow (where the body starts) + * @param endPoint the destination point of the arrow (where the tip points to) + * @param bodyRadius the radius of the cylindrical body + * @param tipRadius the radius of the cone base at the tip + * @param tipLength the length of the conical tip + * @param segments the number of segments for cylinder and cone smoothness. + * Higher values create smoother arrows. Minimum is 3. + * @param appearance the line appearance (color, width) used for all lines + */ +public WireframeArrow(final Point3D startPoint, final Point3D endPoint, + final double bodyRadius, final double tipRadius, + final double tipLength, final int segments, + final LineAppearance appearance) { + super(); + + // Calculate direction and distance + final double dx = endPoint.x - startPoint.x; + final double dy = endPoint.y - startPoint.y; + final double dz = endPoint.z - startPoint.z; + final double distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + + // Handle degenerate case: start and end are the same point + if (distance < 0.001) { + return; + } + + // Normalize direction vector + final double nx = dx / distance; + final double ny = dy / distance; + final double nz = dz / distance; + + // Calculate rotation to align Y-axis with direction + // Default arrow points in -Y direction (apex at lower Y) + // We need to rotate from (0, -1, 0) to (nx, ny, nz) + final Quaternion rotation = createRotationFromYAxis(nx, ny, nz); + final Matrix3x3 rotMatrix = rotation.toMatrix(); + + // Calculate body length (distance minus tip) + final double bodyLength = Math.max(0, distance - tipLength); + + // Build the arrow components + if (bodyLength > 0) { + addCylinderBody(startPoint, bodyRadius, bodyLength, segments, appearance, rotMatrix, nx, ny, nz); + } + addConeTip(endPoint, tipRadius, tipLength, segments, appearance, rotMatrix, nx, ny, nz); + } + + /** + * Creates a quaternion that rotates from the -Y axis to the given direction. + * + *

The arrow by default points in the -Y direction. This method computes + * the rotation needed to align the arrow with the target direction vector.

+ * + * @param nx normalized direction X component + * @param ny normalized direction Y component + * @param nz normalized direction Z component + * @return quaternion representing the rotation + */ + private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) { + // Default direction is -Y (0, -1, 0) + // Target direction is (nx, ny, nz) + // Dot product: 0*nx + (-1)*ny + 0*nz = -ny + final double dot = -ny; + + // Check for parallel vectors + if (dot > 0.9999) { + // Direction is nearly -Y, no rotation needed + return Quaternion.identity(); + } + if (dot < -0.9999) { + // Direction is nearly +Y, rotate 180° around X axis + return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI); + } + + // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx) + // This gives the rotation axis + final double axisX = -nz; + final double axisY = 0; + final double axisZ = nx; + final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ); + final double normalizedAxisX = axisX / axisLength; + final double normalizedAxisZ = axisZ / axisLength; + + // Angle from dot product + final double angle = Math.acos(dot); + + return Quaternion.fromAxisAngle( + new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle); + } + + /** + * Adds the cylindrical body of the arrow. + * + *

Local coordinate system: The arrow points in -Y direction in local space. + * Therefore, local -Y is toward the tip (front), and local +Y is toward the start (back).

+ * + * @param startPoint the origin of the arrow body + * @param radius the radius of the cylinder + * @param length the length of the cylinder + * @param segments the number of segments around the circumference + * @param appearance the line appearance + * @param rotMatrix the rotation matrix to apply + * @param dirX direction X component (for translation calculation) + * @param dirY direction Y component + * @param dirZ direction Z component + */ + private void addCylinderBody(final Point3D startPoint, final double radius, + final double length, final int segments, + final LineAppearance appearance, final Matrix3x3 rotMatrix, + final double dirX, final double dirY, final double dirZ) { + // Cylinder center is at startPoint + (length/2) * direction + final double centerX = startPoint.x + (length / 2.0) * dirX; + final double centerY = startPoint.y + (length / 2.0) * dirY; + final double centerZ = startPoint.z + (length / 2.0) * dirZ; + + // Generate ring vertices in local space, then rotate and translate + // Arrow points in -Y direction, so: + // - tipSideRing is at local -Y (toward arrow tip, front of cylinder) + // - startSideRing is at local +Y (toward arrow start, back of cylinder) + final Point3D[] tipSideRing = new Point3D[segments]; + final Point3D[] startSideRing = new Point3D[segments]; + + final double halfLength = length / 2.0; + + for (int i = 0; i < segments; i++) { + final double angle = 2.0 * Math.PI * i / segments; + final double localX = radius * Math.cos(angle); + final double localZ = radius * Math.sin(angle); + + // Tip-side ring (at -halfLength in local Y = toward arrow tip) + final Point3D tipSideLocal = new Point3D(localX, -halfLength, localZ); + rotMatrix.transform(tipSideLocal, tipSideLocal); + tipSideLocal.x += centerX; + tipSideLocal.y += centerY; + tipSideLocal.z += centerZ; + tipSideRing[i] = tipSideLocal; + + // Start-side ring (at +halfLength in local Y = toward arrow start) + final Point3D startSideLocal = new Point3D(localX, halfLength, localZ); + rotMatrix.transform(startSideLocal, startSideLocal); + startSideLocal.x += centerX; + startSideLocal.y += centerY; + startSideLocal.z += centerZ; + startSideRing[i] = startSideLocal; + } + + // Create the circular rings + for (int i = 0; i < segments; i++) { + final int next = (i + 1) % segments; + + // Tip-side ring line segment + addShape(appearance.getLine( + new Point3D(tipSideRing[i].x, tipSideRing[i].y, tipSideRing[i].z), + new Point3D(tipSideRing[next].x, tipSideRing[next].y, tipSideRing[next].z))); + + // Start-side ring line segment + addShape(appearance.getLine( + new Point3D(startSideRing[i].x, startSideRing[i].y, startSideRing[i].z), + new Point3D(startSideRing[next].x, startSideRing[next].y, startSideRing[next].z))); + } + + // Create vertical lines connecting the two rings + for (int i = 0; i < segments; i++) { + addShape(appearance.getLine( + new Point3D(tipSideRing[i].x, tipSideRing[i].y, tipSideRing[i].z), + new Point3D(startSideRing[i].x, startSideRing[i].y, startSideRing[i].z))); + } + } + + /** + * Adds the conical tip of the arrow. + * + *

Local coordinate system: In local space, the cone points in -Y direction + * (apex at lower Y). The base ring is at Y=0, and the apex is at Y=-length.

+ * + * @param endPoint the position of the arrow tip (cone apex) + * @param radius the radius of the cone base + * @param length the length of the cone + * @param segments the number of segments around the circumference + * @param appearance the line appearance + * @param rotMatrix the rotation matrix to apply + * @param dirX direction X component + * @param dirY direction Y component + * @param dirZ direction Z component + */ + private void addConeTip(final Point3D endPoint, final double radius, + final double length, final int segments, + final LineAppearance appearance, final Matrix3x3 rotMatrix, + final double dirX, final double dirY, final double dirZ) { + // Apex is at endPoint (the arrow tip) + // Base center is at endPoint - length * direction (toward arrow start) + final double baseCenterX = endPoint.x - length * dirX; + final double baseCenterY = endPoint.y - length * dirY; + final double baseCenterZ = endPoint.z - length * dirZ; + + // Generate base ring vertices + // In local space, cone points in -Y direction, so base is at Y=0 + final Point3D[] baseRing = new Point3D[segments]; + + for (int i = 0; i < segments; i++) { + final double angle = 2.0 * Math.PI * i / segments; + final double localX = radius * Math.cos(angle); + final double localZ = radius * Math.sin(angle); + + // Base ring vertices at local Y=0 + final Point3D local = new Point3D(localX, 0, localZ); + rotMatrix.transform(local, local); + local.x += baseCenterX; + local.y += baseCenterY; + local.z += baseCenterZ; + baseRing[i] = local; + } + + // Apex point (the arrow tip) + final Point3D apex = new Point3D(endPoint.x, endPoint.y, endPoint.z); + + // Create the circular base ring + for (int i = 0; i < segments; i++) { + final int next = (i + 1) % segments; + addShape(appearance.getLine( + new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z), + new Point3D(baseRing[next].x, baseRing[next].y, baseRing[next].z))); + } + + // Create lines from apex to each base vertex + for (int i = 0; i < segments; i++) { + addShape(appearance.getLine( + new Point3D(apex.x, apex.y, apex.z), + new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z))); + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.java new file mode 100755 index 0000000..a4cc4b7 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.java @@ -0,0 +1,104 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe; + +import eu.svjatoslav.aukio.e3d.geometry.Box; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A wireframe box (rectangular parallelepiped) composed of 12 line segments + * representing the edges of the box. The box is axis-aligned, defined by two + * diagonally opposite corner points. + * + *

The wireframe consists of four edges along each axis: four edges parallel + * to X, four parallel to Y, and four parallel to Z.

+ * + *

Vertex layout:

+ *
+ *         cornerB (max) ────────┐
+ *              /│              /│
+ *             / │             / │
+ *            /  │            /  │
+ *           ┌───┼───────────┐   │
+ *           │   │           │   │
+ *           │   │           │   │
+ *           │   └───────────│───┘
+ *           │  /            │  /
+ *           │ /             │ /
+ *           │/              │/
+ *           └───────────────┘ cornerA (min)
+ * 
+ * + *

Usage example:

+ *
{@code
+ * LineAppearance appearance = new LineAppearance(2, Color.GREEN);
+ * Point3D cornerA = new Point3D(-50, -50, -50);
+ * Point3D cornerB = new Point3D(50, 50, 50);
+ * WireframeBox box = new WireframeBox(cornerA, cornerB, appearance);
+ * shapeCollection.addShape(box);
+ * }
+ * + * @see WireframeCube + * @see Box + * @see LineAppearance + * @see AbstractCompositeShape + */ +public class WireframeBox extends AbstractCompositeShape { + + /** + * Constructs a wireframe box from a {@link Box} geometry object. + * + * @param box the axis-aligned box defining the two opposite corners + * @param appearance the line appearance (color, width) used for all 12 edges + */ + public WireframeBox(final Box box, + final LineAppearance appearance) { + + this(box.p1, box.p2, appearance); + } + + /** + * Constructs a wireframe box from two diagonally opposite corner points. + * The corners do not need to be in any particular min/max order; the constructor + * uses each coordinate independently to form all eight vertices of the box. + * + * @param cornerA the first corner point of the box + * @param cornerB the diagonally opposite corner point of the box + * @param appearance the line appearance (color, width) used for all 12 edges + */ + public WireframeBox(final Point3D cornerA, final Point3D cornerB, + final LineAppearance appearance) { + super(); + + // Determine actual min/max bounds (corners may be in any order) + final double minX = Math.min(cornerA.x, cornerB.x); + final double maxX = Math.max(cornerA.x, cornerB.x); + final double minY = Math.min(cornerA.y, cornerB.y); + final double maxY = Math.max(cornerA.y, cornerB.y); + final double minZ = Math.min(cornerA.z, cornerB.z); + final double maxZ = Math.max(cornerA.z, cornerB.z); + + // Generate the 12 edges of the box + // Four edges along X axis (varying X, fixed Y and Z) + addShape(appearance.getLine(new Point3D(minX, minY, minZ), new Point3D(maxX, minY, minZ))); + addShape(appearance.getLine(new Point3D(minX, maxY, minZ), new Point3D(maxX, maxY, minZ))); + addShape(appearance.getLine(new Point3D(minX, minY, maxZ), new Point3D(maxX, minY, maxZ))); + addShape(appearance.getLine(new Point3D(minX, maxY, maxZ), new Point3D(maxX, maxY, maxZ))); + + // Four edges along Y axis (varying Y, fixed X and Z) + addShape(appearance.getLine(new Point3D(minX, minY, minZ), new Point3D(minX, maxY, minZ))); + addShape(appearance.getLine(new Point3D(maxX, minY, minZ), new Point3D(maxX, maxY, minZ))); + addShape(appearance.getLine(new Point3D(minX, minY, maxZ), new Point3D(minX, maxY, maxZ))); + addShape(appearance.getLine(new Point3D(maxX, minY, maxZ), new Point3D(maxX, maxY, maxZ))); + + // Four edges along Z axis (varying Z, fixed X and Y) + addShape(appearance.getLine(new Point3D(minX, minY, minZ), new Point3D(minX, minY, maxZ))); + addShape(appearance.getLine(new Point3D(maxX, minY, minZ), new Point3D(maxX, minY, maxZ))); + addShape(appearance.getLine(new Point3D(minX, maxY, minZ), new Point3D(minX, maxY, maxZ))); + addShape(appearance.getLine(new Point3D(maxX, maxY, minZ), new Point3D(maxX, maxY, maxZ))); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCone.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCone.java new file mode 100644 index 0000000..9945e65 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCone.java @@ -0,0 +1,247 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.math.Matrix3x3; +import eu.svjatoslav.aukio.e3d.math.Quaternion; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A wireframe cone that can be oriented in any direction. + * + *

The cone has a circular base and a single apex (tip) point. The wireframe + * consists of:

+ *
    + *
  • A circular ring at the base
  • + *
  • Lines from each base vertex to the apex
  • + *
+ * + *

Two constructors are provided for different use cases:

+ * + *
    + *
  • Directional (recommended): Specify apex point and base center point. + * The cone points from apex toward the base center. This allows arbitrary + * orientation and is the most intuitive API.
  • + *
  • Y-axis aligned: Specify base center, radius, and height. The cone + * points in -Y direction (apex at lower Y). Useful for simple vertical cones.
  • + *
+ * + *

Usage examples:

+ *
{@code
+ * // Directional constructor: cone pointing from apex toward base
+ * LineAppearance appearance = new LineAppearance(2, Color.RED);
+ * WireframeCone directionalCone = new WireframeCone(
+ *     new Point3D(0, -100, 0),   // apex (tip of the cone)
+ *     new Point3D(0, 50, 0),     // baseCenter (cone points toward this)
+ *     50,                        // radius of the circular base
+ *     16,                        // segments
+ *     appearance
+ * );
+ *
+ * // Y-axis aligned constructor: cone pointing upward
+ * WireframeCone verticalCone = new WireframeCone(
+ *     new Point3D(0, 0, 300),    // baseCenter
+ *     50,                        // radius
+ *     100,                       // height
+ *     16,                        // segments
+ *     appearance
+ * );
+ * }
+ * + * @see WireframeCylinder + * @see WireframeArrow + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCone + */ +public class WireframeCone extends AbstractCompositeShape { + + /** + * Constructs a wireframe cone pointing from apex toward base center. + * + *

This is the recommended constructor for placing cones in 3D space. + * The cone's apex (tip) is at {@code apexPoint}, and the circular base + * is centered at {@code baseCenterPoint}. The cone points in the direction + * from apex to base center.

+ * + *

Coordinate interpretation:

+ *
    + *
  • {@code apexPoint} - the sharp tip of the cone
  • + *
  • {@code baseCenterPoint} - the center of the circular base; the cone + * "points" in this direction from the apex
  • + *
  • The distance between apex and base center determines the cone height
  • + *
+ * + * @param apexPoint the position of the cone's tip (apex) + * @param baseCenterPoint the center point of the circular base; the cone + * points from apex toward this point + * @param radius the radius of the circular base + * @param segments the number of segments around the circumference. + * Higher values create smoother cones. Minimum is 3. + * @param appearance the line appearance (color, width) used for all lines + */ + public WireframeCone(final Point3D apexPoint, final Point3D baseCenterPoint, + final double radius, final int segments, + final LineAppearance appearance) { + super(); + + // Calculate direction and height from apex to base center + final double dx = baseCenterPoint.x - apexPoint.x; + final double dy = baseCenterPoint.y - apexPoint.y; + final double dz = baseCenterPoint.z - apexPoint.z; + final double height = Math.sqrt(dx * dx + dy * dy + dz * dz); + + // Handle degenerate case: apex and base center are the same point + if (height < 0.001) { + return; + } + + // Normalize direction vector (from apex toward base) + final double nx = dx / height; + final double ny = dy / height; + final double nz = dz / height; + + // Calculate rotation to align Y-axis with direction + // Default cone points in -Y direction (apex at origin, base at -Y) + // We need to rotate from (0, -1, 0) to (nx, ny, nz) + final Quaternion rotation = createRotationFromYAxis(nx, ny, nz); + final Matrix3x3 rotMatrix = rotation.toMatrix(); + + // Generate base ring vertices in local space, then rotate and translate + // In local space: apex is at origin, base is at Y = -height + // (cone points in -Y direction in local space) + final Point3D[] baseRing = new Point3D[segments]; + + for (int i = 0; i < segments; i++) { + final double angle = 2.0 * Math.PI * i / segments; + final double localX = radius * Math.cos(angle); + final double localZ = radius * Math.sin(angle); + + // Base ring vertex in local space (Y = -height) + final Point3D local = new Point3D(localX, -height, localZ); + rotMatrix.transform(local, local); + local.x += apexPoint.x; + local.y += apexPoint.y; + local.z += apexPoint.z; + baseRing[i] = local; + } + + // Apex point (the cone tip) + final Point3D apex = new Point3D(apexPoint.x, apexPoint.y, apexPoint.z); + + // Create the circular base ring + for (int i = 0; i < segments; i++) { + final int next = (i + 1) % segments; + addShape(appearance.getLine( + new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z), + new Point3D(baseRing[next].x, baseRing[next].y, baseRing[next].z))); + } + + // Create lines from apex to each base vertex + for (int i = 0; i < segments; i++) { + addShape(appearance.getLine( + new Point3D(apex.x, apex.y, apex.z), + new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z))); + } + } + + /** + * Constructs a wireframe cone with circular base centered at the given point, + * pointing in the -Y direction. + * + *

This constructor creates a Y-axis aligned cone. The apex is positioned + * at {@code baseCenter.y - height} (above the base in the negative Y direction). + * For cones pointing in arbitrary directions, use + * {@link #WireframeCone(Point3D, Point3D, double, int, LineAppearance)} instead.

+ * + *

Coordinate system: The cone points in -Y direction (apex at lower Y). + * The base is at Y=baseCenter.y, and the apex is at Y=baseCenter.y - height. + * In Aukio 3D's coordinate system, "up" visually is negative Y.

+ * + * @param baseCenter the center point of the cone's circular base in 3D space + * @param radius the radius of the circular base + * @param height the height of the cone from base center to apex + * @param segments the number of segments around the circumference. + * Higher values create smoother cones. Minimum is 3. + * @param appearance the line appearance (color, width) used for all lines + */ + public WireframeCone(final Point3D baseCenter, final double radius, + final double height, final int segments, + final LineAppearance appearance) { + super(); + + // Apex is above the base (negative Y direction in this coordinate system) + final double apexY = baseCenter.y - height; + final Point3D apex = new Point3D(baseCenter.x, apexY, baseCenter.z); + + // Generate vertices around the circular base + final Point3D[] baseRing = new Point3D[segments]; + for (int i = 0; i < segments; i++) { + final double angle = 2.0 * Math.PI * i / segments; + final double x = baseCenter.x + radius * Math.cos(angle); + final double z = baseCenter.z + radius * Math.sin(angle); + baseRing[i] = new Point3D(x, baseCenter.y, z); + } + + // Create the circular base ring + for (int i = 0; i < segments; i++) { + final int next = (i + 1) % segments; + addShape(appearance.getLine( + new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z), + new Point3D(baseRing[next].x, baseRing[next].y, baseRing[next].z))); + } + + // Create lines from apex to each base vertex + for (int i = 0; i < segments; i++) { + addShape(appearance.getLine( + new Point3D(apex.x, apex.y, apex.z), + new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z))); + } + } + + /** + * Creates a quaternion that rotates from the -Y axis to the given direction. + * + *

The cone by default points in the -Y direction (apex at origin, base at -Y). + * This method computes the rotation needed to align the cone with the target + * direction vector.

+ * + * @param nx normalized direction X component + * @param ny normalized direction Y component + * @param nz normalized direction Z component + * @return quaternion representing the rotation + */ + private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) { + // Default direction is -Y (0, -1, 0) + // Target direction is (nx, ny, nz) + // Dot product: 0*nx + (-1)*ny + 0*nz = -ny + final double dot = -ny; + + // Check for parallel vectors + if (dot > 0.9999) { + // Direction is nearly -Y, no rotation needed + return Quaternion.identity(); + } + if (dot < -0.9999) { + // Direction is nearly +Y, rotate 180° around X axis + return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI); + } + + // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx) + // This gives the rotation axis + final double axisX = -nz; + final double axisY = 0; + final double axisZ = nx; + final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ); + final double normalizedAxisX = axisX / axisLength; + final double normalizedAxisZ = axisZ / axisLength; + + // Angle from dot product + final double angle = Math.acos(dot); + + return Quaternion.fromAxisAngle( + new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCube.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCube.java new file mode 100755 index 0000000..7bbbd3f --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCube.java @@ -0,0 +1,45 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance; + +/** + * A wireframe cube (equal-length sides) centered at a given point in 3D space. + * This is a convenience subclass of {@link WireframeBox} that constructs an + * axis-aligned cube from a center point and a half-side length. + * + *

The cube extends {@code size} units in each direction from the center, + * resulting in a total edge length of {@code 2 * size}.

+ * + *

Usage example:

+ *
{@code
+ * LineAppearance appearance = new LineAppearance(1, Color.CYAN);
+ * WireframeCube cube = new WireframeCube(new Point3D(0, 0, 200), 50, appearance);
+ * shapeCollection.addShape(cube);
+ * }
+ * + * @see WireframeBox + * @see LineAppearance + */ +public class WireframeCube extends WireframeBox { + + /** + * Constructs a wireframe cube centered at the given point. + * + * @param center the center point of the cube in 3D space + * @param size the half-side length; the cube extends this distance from + * the center along each axis, giving a total edge length + * of {@code 2 * size} + * @param appearance the line appearance (color, width) used for all 12 edges + */ + public WireframeCube(final Point3D center, final double size, + final LineAppearance appearance) { + super(new Point3D(center.x - size, center.y - size, center.z - size), + new Point3D(center.x + size, center.y + size, center.z + size), + appearance); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCylinder.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCylinder.java new file mode 100644 index 0000000..30988fa --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCylinder.java @@ -0,0 +1,188 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.math.Matrix3x3; +import eu.svjatoslav.aukio.e3d.math.Quaternion; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A wireframe cylinder defined by two end points. + * + *

The cylinder extends from startPoint to endPoint with circular rings at both + * ends. The number of segments determines the smoothness of the circular rings. + * The wireframe consists of:

+ *
    + *
  • Two circular rings at the start and end points
  • + *
  • Vertical lines connecting corresponding vertices between the rings
  • + *
+ * + *

Usage example:

+ *
{@code
+ * // Create a vertical cylinder from Y=100 to Y=200
+ * LineAppearance appearance = new LineAppearance(2, Color.RED);
+ * WireframeCylinder cylinder = new WireframeCylinder(
+ *     new Point3D(0, 100, 0),   // start point (bottom)
+ *     new Point3D(0, 200, 0),   // end point (top)
+ *     10,                        // radius
+ *     16,                        // segments
+ *     appearance
+ * );
+ *
+ * // Create a horizontal cylinder along X axis
+ * WireframeCylinder pipe = new WireframeCylinder(
+ *     new Point3D(-50, 0, 0),
+ *     new Point3D(50, 0, 0),
+ *     5, 12, appearance
+ * );
+ * }
+ * + * @see WireframeCone + * @see WireframeArrow + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCylinder + */ +public class WireframeCylinder extends AbstractCompositeShape { + + /** + * Constructs a wireframe cylinder between two end points. + * + *

The cylinder has circular rings at both startPoint and endPoint, + * connected by lines between corresponding vertices. The orientation is + * automatically calculated from the direction between the two points.

+ * + * @param startPoint the center of the first ring + * @param endPoint the center of the second ring + * @param radius the radius of the cylinder + * @param segments the number of segments around the circumference. + * Higher values create smoother cylinders. Minimum is 3. + * @param appearance the line appearance (color, width) used for all lines + */ + public WireframeCylinder(final Point3D startPoint, final Point3D endPoint, + final double radius, final int segments, + final LineAppearance appearance) { + super(); + + // Calculate direction and distance + final double dx = endPoint.x - startPoint.x; + final double dy = endPoint.y - startPoint.y; + final double dz = endPoint.z - startPoint.z; + final double distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + + // Handle degenerate case: start and end are the same point + if (distance < 0.001) { + return; + } + + // Normalize direction vector + final double nx = dx / distance; + final double ny = dy / distance; + final double nz = dz / distance; + + // Calculate rotation to align Y-axis with direction + // Default cylinder is aligned along Y-axis + // We need to rotate from (0, 1, 0) to (nx, ny, nz) + final Quaternion rotation = createRotationFromYAxis(nx, ny, nz); + final Matrix3x3 rotMatrix = rotation.toMatrix(); + + // Cylinder center is at midpoint between start and end + final double centerX = (startPoint.x + endPoint.x) / 2.0; + final double centerY = (startPoint.y + endPoint.y) / 2.0; + final double centerZ = (startPoint.z + endPoint.z) / 2.0; + final double halfLength = distance / 2.0; + + // Generate ring vertices in local space, then rotate and translate + // In local space: cylinder is aligned along Y-axis + // - startRing is at local -Y (toward startPoint) + // - endRing is at local +Y (toward endPoint) + final Point3D[] startRing = new Point3D[segments]; + final Point3D[] endRing = new Point3D[segments]; + + for (int i = 0; i < segments; i++) { + final double angle = 2.0 * Math.PI * i / segments; + final double localX = radius * Math.cos(angle); + final double localZ = radius * Math.sin(angle); + + // Start ring (at -halfLength in local Y = toward startPoint) + final Point3D startLocal = new Point3D(localX, -halfLength, localZ); + rotMatrix.transform(startLocal, startLocal); + startLocal.x += centerX; + startLocal.y += centerY; + startLocal.z += centerZ; + startRing[i] = startLocal; + + // End ring (at +halfLength in local Y = toward endPoint) + final Point3D endLocal = new Point3D(localX, halfLength, localZ); + rotMatrix.transform(endLocal, endLocal); + endLocal.x += centerX; + endLocal.y += centerY; + endLocal.z += centerZ; + endRing[i] = endLocal; + } + + // Create the circular rings + for (int i = 0; i < segments; i++) { + final int next = (i + 1) % segments; + + // Start ring line segment + addShape(appearance.getLine( + new Point3D(startRing[i].x, startRing[i].y, startRing[i].z), + new Point3D(startRing[next].x, startRing[next].y, startRing[next].z))); + + // End ring line segment + addShape(appearance.getLine( + new Point3D(endRing[i].x, endRing[i].y, endRing[i].z), + new Point3D(endRing[next].x, endRing[next].y, endRing[next].z))); + } + + // Create vertical lines connecting the two rings + for (int i = 0; i < segments; i++) { + addShape(appearance.getLine( + new Point3D(startRing[i].x, startRing[i].y, startRing[i].z), + new Point3D(endRing[i].x, endRing[i].y, endRing[i].z))); + } + } + + /** + * Creates a quaternion that rotates from the +Y axis to the given direction. + * + * @param nx normalized direction X component + * @param ny normalized direction Y component + * @param nz normalized direction Z component + * @return quaternion representing the rotation + */ + private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) { + // Default direction is +Y (0, 1, 0) + // Target direction is (nx, ny, nz) + // Dot product: 0*nx + 1*ny + 0*nz = ny + final double dot = ny; + + // Check for parallel vectors + if (dot > 0.9999) { + // Direction is nearly +Y, no rotation needed + return Quaternion.identity(); + } + if (dot < -0.9999) { + // Direction is nearly -Y, rotate 180° around X axis + return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI); + } + + // Cross product: (0, 1, 0) x (nx, ny, nz) = (nz, 0, -nx) + // This gives the rotation axis + final double axisX = nz; + final double axisY = 0; + final double axisZ = -nx; + final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ); + final double normalizedAxisX = axisX / axisLength; + final double normalizedAxisZ = axisZ / axisLength; + + // Angle from dot product + final double angle = Math.acos(dot); + + return Quaternion.fromAxisAngle( + new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeDrawing.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeDrawing.java new file mode 100755 index 0000000..1510ea4 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeDrawing.java @@ -0,0 +1,75 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A freeform polyline drawing tool that connects sequential points with line + * segments. Points are added one at a time via {@link #addPoint(Point3D)}; + * each new point is connected to the previously added point by a line. + * + *

The first point added establishes the starting position without drawing + * a line. Each subsequent point creates a new line segment from the previous + * point to the new one.

+ * + *

This shape is useful for drawing paths, trails, trajectories, or + * arbitrary wireframe shapes that are defined as a sequence of vertices.

+ * + *

Usage example:

+ *
{@code
+ * LineAppearance appearance = new LineAppearance(2, Color.YELLOW);
+ * WireframeDrawing drawing = new WireframeDrawing(appearance);
+ * drawing.addPoint(new Point3D(0, 0, 0));
+ * drawing.addPoint(new Point3D(100, 50, 0));
+ * drawing.addPoint(new Point3D(200, 0, 0));
+ * shapeCollection.addShape(drawing);
+ * }
+ * + * @see LineAppearance + * @see AbstractCompositeShape + */ +public class WireframeDrawing extends AbstractCompositeShape { + + /** The line appearance used for all segments in this drawing. */ + final private LineAppearance lineAppearance; + + /** The most recently added point, used as the start of the next line segment. */ + Point3D currentPoint; + + /** + * Constructs a new empty wireframe drawing with the given line appearance. + * + * @param lineAppearance the line appearance (color, width) used for all + * line segments added to this drawing + */ + public WireframeDrawing(final LineAppearance lineAppearance) { + super(); + this.lineAppearance = lineAppearance; + } + + /** + * Adds a new point to the drawing. If this is the first point, it sets the + * starting position. Otherwise, a line segment is created from the previous + * point to this new point. + * + *

The point is defensively copied, so subsequent modifications to the + * passed {@code point3d} object will not affect the drawing.

+ * + * @param point3d the point to add to the polyline + */ + public void addPoint(final Point3D point3d) { + if (currentPoint != null) { + final Line line = lineAppearance.getLine(currentPoint, point3d); + addShape(line); + } + + currentPoint = new Point3D(point3d); + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframePyramid.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframePyramid.java new file mode 100644 index 0000000..fe04179 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframePyramid.java @@ -0,0 +1,246 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.math.Matrix3x3; +import eu.svjatoslav.aukio.e3d.math.Quaternion; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +/** + * A wireframe square-based pyramid that can be oriented in any direction. + * + *

The pyramid has a square base and four triangular faces meeting at an apex + * (tip). The wireframe consists of:

+ *
    + *
  • Four lines forming the square base
  • + *
  • Four lines from each base corner to the apex
  • + *
+ * + *

Two constructors are provided for different use cases:

+ * + *
    + *
  • Directional (recommended): Specify apex point and base center point. + * The pyramid points from apex toward the base center. This allows arbitrary + * orientation and is the most intuitive API.
  • + *
  • Y-axis aligned: Specify base center, base size, and height. The pyramid + * points in -Y direction (apex at lower Y). Useful for simple vertical pyramids.
  • + *
+ * + *

Usage examples:

+ *
{@code
+ * // Directional constructor: pyramid pointing from apex toward base
+ * LineAppearance appearance = new LineAppearance(2, Color.RED);
+ * WireframePyramid directionalPyramid = new WireframePyramid(
+ *     new Point3D(0, -100, 0),   // apex (tip of the pyramid)
+ *     new Point3D(0, 50, 0),     // baseCenter (pyramid points toward this)
+ *     50,                        // baseSize (half-width of square base)
+ *     appearance
+ * );
+ *
+ * // Y-axis aligned constructor: pyramid pointing upward
+ * WireframePyramid verticalPyramid = new WireframePyramid(
+ *     new Point3D(0, 0, 300),    // baseCenter
+ *     50,                        // baseSize (half-width of square base)
+ *     100,                       // height
+ *     appearance
+ * );
+ * }
+ * + * @see WireframeCone + * @see WireframeCube + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonPyramid + */ +public class WireframePyramid extends AbstractCompositeShape { + + /** + * Constructs a wireframe square-based pyramid pointing from apex toward base center. + * + *

This is the recommended constructor for placing pyramids in 3D space. + * The pyramid's apex (tip) is at {@code apexPoint}, and the square base + * is centered at {@code baseCenter}. The pyramid points in the direction + * from apex to base center.

+ * + *

Coordinate interpretation:

+ *
    + *
  • {@code apexPoint} - the sharp tip of the pyramid
  • + *
  • {@code baseCenter} - the center of the square base; the pyramid + * "points" in this direction from the apex
  • + *
  • {@code baseSize} - half the width of the square base; the base + * extends this distance from the center along perpendicular axes
  • + *
  • The distance between apex and base center determines the pyramid height
  • + *
+ * + * @param apexPoint the position of the pyramid's tip (apex) + * @param baseCenter the center point of the square base; the pyramid + * points from apex toward this point + * @param baseSize the half-width of the square base; the base extends + * this distance from the center, giving a total base + * edge length of {@code 2 * baseSize} + * @param appearance the line appearance (color, width) used for all lines + */ + public WireframePyramid(final Point3D apexPoint, final Point3D baseCenter, + final double baseSize, final LineAppearance appearance) { + super(); + + // Calculate direction and height from apex to base center + final double dx = baseCenter.x - apexPoint.x; + final double dy = baseCenter.y - apexPoint.y; + final double dz = baseCenter.z - apexPoint.z; + final double height = Math.sqrt(dx * dx + dy * dy + dz * dz); + + // Handle degenerate case: apex and base center are the same point + if (height < 0.001) { + return; + } + + // Normalize direction vector (from apex toward base) + final double nx = dx / height; + final double ny = dy / height; + final double nz = dz / height; + + // Calculate rotation to align Y-axis with direction + // Default pyramid points in -Y direction (apex at origin, base at -Y) + // We need to rotate from (0, -1, 0) to (nx, ny, nz) + final Quaternion rotation = createRotationFromYAxis(nx, ny, nz); + final Matrix3x3 rotMatrix = rotation.toMatrix(); + + // Generate base corner vertices in local space, then rotate and translate + // In local space: apex is at origin, base is at Y = -height + // Base corners form a square centered at (0, -height, 0) + final double h = baseSize; + final Point3D[] baseCorners = new Point3D[4]; + + // Local space corner positions (before rotation) + // Arranged counter-clockwise when viewed from apex (from +Y) + final double[][] localCorners = { + {-h, -height, -h}, // corner 0: negative X, negative Z + {+h, -height, -h}, // corner 1: positive X, negative Z + {+h, -height, +h}, // corner 2: positive X, positive Z + {-h, -height, +h} // corner 3: negative X, positive Z + }; + + for (int i = 0; i < 4; i++) { + final Point3D local = new Point3D(localCorners[i][0], localCorners[i][1], localCorners[i][2]); + rotMatrix.transform(local, local); + local.x += apexPoint.x; + local.y += apexPoint.y; + local.z += apexPoint.z; + baseCorners[i] = local; + } + + // Apex point (the pyramid tip) + final Point3D apex = new Point3D(apexPoint.x, apexPoint.y, apexPoint.z); + + // Create the four lines forming the square base + for (int i = 0; i < 4; i++) { + final int next = (i + 1) % 4; + addShape(appearance.getLine( + new Point3D(baseCorners[i].x, baseCorners[i].y, baseCorners[i].z), + new Point3D(baseCorners[next].x, baseCorners[next].y, baseCorners[next].z))); + } + + // Create the four lines from apex to each base corner + for (int i = 0; i < 4; i++) { + addShape(appearance.getLine( + new Point3D(apex.x, apex.y, apex.z), + new Point3D(baseCorners[i].x, baseCorners[i].y, baseCorners[i].z))); + } + } + + /** + * Constructs a wireframe square-based pyramid with base centered at the given point, + * pointing in the -Y direction. + * + *

This constructor creates a Y-axis aligned pyramid. The apex is positioned + * at {@code baseCenter.y - height} (above the base in the negative Y direction). + * For pyramids pointing in arbitrary directions, use + * {@link #WireframePyramid(Point3D, Point3D, double, LineAppearance)} instead.

+ * + *

Coordinate system: The pyramid points in -Y direction (apex at lower Y). + * The base is at Y=baseCenter.y, and the apex is at Y=baseCenter.y - height. + * In Aukio 3D's coordinate system, "up" visually is negative Y.

+ * + * @param baseCenter the center point of the pyramid's base in 3D space + * @param baseSize the half-width of the square base; the base extends + * this distance from the center along X and Z axes, + * giving a total base edge length of {@code 2 * baseSize} + * @param height the height of the pyramid from base center to apex + * @param appearance the line appearance (color, width) used for all lines + */ + public WireframePyramid(final Point3D baseCenter, final double baseSize, + final double height, final LineAppearance appearance) { + super(); + + final double halfBase = baseSize; + final double apexY = baseCenter.y - height; + final double baseY = baseCenter.y; + + // Base corners arranged counter-clockwise when viewed from above (+Y) + // Naming: "negative/positive X" and "negative/positive Z" relative to base center + final Point3D negXnegZ = new Point3D(baseCenter.x - halfBase, baseY, baseCenter.z - halfBase); + final Point3D posXnegZ = new Point3D(baseCenter.x + halfBase, baseY, baseCenter.z - halfBase); + final Point3D posXposZ = new Point3D(baseCenter.x + halfBase, baseY, baseCenter.z + halfBase); + final Point3D negXposZ = new Point3D(baseCenter.x - halfBase, baseY, baseCenter.z + halfBase); + final Point3D apex = new Point3D(baseCenter.x, apexY, baseCenter.z); + + // Create the four lines forming the square base + addShape(appearance.getLine(negXnegZ, posXnegZ)); + addShape(appearance.getLine(posXnegZ, posXposZ)); + addShape(appearance.getLine(posXposZ, negXposZ)); + addShape(appearance.getLine(negXposZ, negXnegZ)); + + // Create the four lines from apex to each base corner + addShape(appearance.getLine(apex, negXnegZ)); + addShape(appearance.getLine(apex, posXnegZ)); + addShape(appearance.getLine(apex, posXposZ)); + addShape(appearance.getLine(apex, negXposZ)); + } + + /** + * Creates a quaternion that rotates from the -Y axis to the given direction. + * + *

The pyramid by default points in the -Y direction (apex at origin, base at -Y). + * This method computes the rotation needed to align the pyramid with the target + * direction vector.

+ * + * @param nx normalized direction X component + * @param ny normalized direction Y component + * @param nz normalized direction Z component + * @return quaternion representing the rotation + */ + private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) { + // Default direction is -Y (0, -1, 0) + // Target direction is (nx, ny, nz) + // Dot product: 0*nx + (-1)*ny + 0*nz = -ny + final double dot = -ny; + + // Check for parallel vectors + if (dot > 0.9999) { + // Direction is nearly -Y, no rotation needed + return Quaternion.identity(); + } + if (dot < -0.9999) { + // Direction is nearly +Y, rotate 180° around X axis + return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI); + } + + // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx) + // This gives the rotation axis + final double axisX = -nz; + final double axisY = 0; + final double axisZ = nx; + final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ); + final double normalizedAxisX = axisX / axisLength; + final double normalizedAxisZ = axisZ / axisLength; + + // Angle from dot product + final double angle = Math.acos(dot); + + return Quaternion.fromAxisAngle( + new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeSphere.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeSphere.java new file mode 100755 index 0000000..0a74e97 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeSphere.java @@ -0,0 +1,87 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; + +import java.util.ArrayList; + +/** + * A wireframe sphere approximation built from rings of connected line segments. + * The sphere is generated using parametric spherical coordinates, producing a + * latitude-longitude grid of vertices connected by lines. + * + *

The sphere is divided into 20 longitudinal slices and 20 latitudinal rings + * (using a step of {@code PI / 10} radians). Adjacent vertices within each ring + * are connected, and corresponding vertices between consecutive rings are also + * connected, forming a mesh that approximates a sphere surface.

+ * + *

Usage example:

+ *
{@code
+ * LineAppearance appearance = new LineAppearance(1, Color.WHITE);
+ * WireframeSphere sphere = new WireframeSphere(new Point3D(0, 0, 300), 100f, appearance);
+ * shapeCollection.addShape(sphere);
+ * }
+ * + * @see LineAppearance + * @see AbstractCompositeShape + */ +public class WireframeSphere extends AbstractCompositeShape { + + /** Stores the vertices of the previously generated ring for inter-ring connections. */ + ArrayList previousRing = new ArrayList<>(); + + /** + * Constructs a wireframe sphere at the given location with the specified radius. + * The sphere is approximated by a grid of line segments generated from + * parametric spherical coordinates. + * + * @param location the center point of the sphere in 3D space + * @param radius the radius of the sphere + * @param lineFactory the line appearance (color, width) used for all line segments + */ + public WireframeSphere(final Point3D location, final float radius, + final LineAppearance lineFactory) { + super(location); + + final double step = Math.PI / 10; + + final Point3D center = new Point3D(); + + int ringIndex = 0; + + for (double j = 0d; j <= (Math.PI * 2); j += step) { + + Point3D oldPoint = null; + int pointIndex = 0; + + for (double i = 0; i <= (Math.PI * 2); i += step) { + final Point3D newPoint = new Point3D(0, 0, radius); + newPoint.rotate(center, i, j); + + if (oldPoint != null) + addShape(lineFactory.getLine(newPoint, oldPoint)); + + if (ringIndex > 0) { + final Point3D previousRingPoint = previousRing + .get(pointIndex); + addShape(lineFactory.getLine(newPoint, previousRingPoint)); + + previousRing.set(pointIndex, newPoint); + } else + previousRing.add(newPoint); + + oldPoint = newPoint; + pointIndex++; + } + + ringIndex++; + } + + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/package-info.java new file mode 100644 index 0000000..1a63289 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/package-info.java @@ -0,0 +1,24 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Wireframe composite shapes built from Line primitives. + * + *

These shapes render as edge-only outlines, useful for visualization, + * debugging, and architectural-style rendering.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.WireframeBox} - A wireframe box
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.WireframeCube} - A wireframe cube
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.WireframeSphere} - A wireframe sphere
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.Grid2D} - A 2D grid plane
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.Grid3D} - A 3D grid volume
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.WireframeBox + */ + +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/package-info.java new file mode 100644 index 0000000..d72bc51 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/package-info.java @@ -0,0 +1,25 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Renderable shape classes for the rasterization pipeline. + * + *

This package contains the shape hierarchy used for 3D rendering:

+ *
    + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape} - Base class for all shapes
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape} - Base for shapes with vertices
  • + *
+ * + *

Subpackages organize shapes by type:

+ *
    + *
  • {@code basic} - Primitive shapes (lines, polygons, billboards)
  • + *
  • {@code composite} - Compound shapes built from primitives (boxes, grids, text)
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape + */ + +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/Texture.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/Texture.java new file mode 100644 index 0000000..92d69b9 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/Texture.java @@ -0,0 +1,479 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.texture; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; +import java.awt.image.WritableRaster; + +import static java.util.Arrays.fill; + +/** + * Represents a 2D texture with mipmap support for level-of-detail rendering. + * + *

A {@code Texture} contains a primary bitmap at native resolution, along with + * cached upscaled and downscaled versions (mipmaps) that are lazily generated on demand. + * This mipmap chain enables efficient texture sampling at varying distances from the camera, + * avoiding aliasing artifacts for distant surfaces and pixelation for close-up views.

+ * + *

The texture also exposes a {@link java.awt.Graphics2D} context backed by the primary + * bitmap's {@link java.awt.image.BufferedImage}, allowing dynamic rendering of text, + * shapes, or other 2D content directly onto the texture surface. Anti-aliasing is + * enabled by default on this graphics context.

+ * + *

Mipmap levels

+ *
    + *
  • Primary bitmap -- the native resolution; always available.
  • + *
  • Downsampled bitmaps -- up to 8 levels, each half the size of the previous. + * Used when the texture is rendered at zoom levels below 1.0.
  • + *
  • Upsampled bitmaps -- configurable count (set at construction time), each + * double the size of the previous. Used when the texture is rendered at zoom levels + * above 2.0.
  • + *
+ * + *

Usage example

+ *
{@code
+ * Texture tex = new Texture(256, 256, 3);
+ * // Draw content using the Graphics2D context
+ * tex.graphics.setColor(java.awt.Color.RED);
+ * tex.graphics.fillRect(0, 0, 256, 256);
+ * // Invalidate cached mipmaps after modifying the primary bitmap
+ * tex.resetResampledBitmapCache();
+ * // Retrieve the appropriate mipmap for a given zoom level
+ * TextureBitmap bitmap = tex.getMipmapForScale(0.5);
+ * }
+ * + * @see TextureBitmap + * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle + */ +public class Texture { + + /** + * When true, texture coordinates outside [0, width/height) wrap + * (tile) instead of clamping to the edge texel. Needed for + * world-planar UVs and tiled game meshes. + */ + public boolean wrap; + + /** + * When true, this texture carries meaningful alpha (cutout TEST or + * BLEND): in z-buffer mode its triangles paint in the second, + * back-to-front alpha pass — depth-tested but not depth-written, so + * they resolve against opaque geometry per-pixel yet keep + * painter-coherent overlap among themselves. Opaque-class triangles + * paint first, front-to-back, writing depth. Set by texture + * providers that know the alpha mode (default false = opaque). + */ + public boolean hasAlpha; + + /** + * The primary (native resolution) bitmap for this texture. + * All dynamic drawing via {@link #graphics} modifies this bitmap's backing data. + */ + public final TextureBitmap primaryBitmap; + + /** + * A {@link java.awt.Graphics2D} context for drawing 2D content onto the primary bitmap. + * Anti-aliasing for both geometry and text is enabled by default. + */ + public final java.awt.Graphics2D graphics; + + /** + * Cached upsampled (enlarged) versions of the primary bitmap. + * Index 0 is 2x the primary, index 1 is 4x, and so on. + * Entries are lazily populated on first access. + */ + TextureBitmap[] upSampled; + + /** + * Cached downsampled (reduced) versions of the primary bitmap. + * Index 0 is 1/2 the primary, index 1 is 1/4, and so on. + * Entries are lazily populated on first access. + */ + TextureBitmap[] downSampled = new TextureBitmap[8]; // TODO: consider renaming it to mipmap to use standard terminology + + /** + * Optional signed-distance-field mask for sharp text/vector-art + * rendering. When non-null, {@code TexturedTriangle} samples this + * mask with bilinear filtering and derives per-pixel coverage from + * it (edge at value ~127.5), mixing the background layer + * ({@link #primaryBitmap}) with {@link #sdfForeground} — instead of + * blending coverage stored in the bitmap itself. The mask is always + * sampled at primary resolution: minification is handled by widening + * the coverage window by the pixel footprint, so no mipmap chain is + * needed and there is no mip-level isosurface drift. + * + *

Values: 0 = deep inside ink, 255 = far outside any ink.

+ */ + public TextureBitmap sdfMask; + + /** + * Hard-edged foreground (ink) color layer for SDF rendering. Read + * only where the coverage derived from {@link #sdfMask} is non-zero, + * so flat per-region fills are sufficient; sampled nearest. + */ + public TextureBitmap sdfForeground; + + /** + * Width of the distance gradient encoded in {@link #sdfMask}, in + * primary-texture texels: mask value 0.5 +/- 0.5 spans + * -sdfSpreadTexels..+sdfSpreadTexels of signed distance. Set by + * whoever generated the mask (see {@code SdfGlyphCache#SPREAD_TEXELS}). + */ + public double sdfSpreadTexels = 2.0; + + /** + * Returns whether this texture renders through the SDF path + * (distance-field mask + separate bg/fg color layers). + * + * @return true when an SDF mask is attached + */ + public boolean isSdf() { + return sdfMask != null; + } + + /** + * Creates a new texture with the specified dimensions and upscale capacity. + * + *

The underlying {@link java.awt.image.BufferedImage} is created using + * {@link eu.svjatoslav.aukio.e3d.gui.RenderingContext#bufferedImageType} for + * compatibility with the raster rendering pipeline.

+ * + * @param width the width of the primary bitmap in pixels + * @param height the height of the primary bitmap in pixels + * @param maxUpscale the maximum number of upscaled mipmap levels to support + * (each level doubles the resolution) + */ + public Texture(final int width, final int height, final int maxUpscale) { + upSampled = new TextureBitmap[maxUpscale]; + + final BufferedImage bufferedImage = new BufferedImage(width, height, + BufferedImage.TYPE_INT_ARGB); + + final WritableRaster raster = bufferedImage.getRaster(); + final DataBufferInt dbi = (DataBufferInt) raster.getDataBuffer(); + graphics = (Graphics2D) bufferedImage.getGraphics(); + + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + + graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, + RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + + primaryBitmap = new TextureBitmap(width, height, dbi.getData(), 1); + } + +/** + * Determines the appropriate downscale mipmap level for a given scale factor. + * + *

Iterates through the downscaled mipmap levels (each halving the size) + * and returns the index of the first level whose effective size falls below + * the requested scale.

+ * + * @param scale the scale factor (typically less than 1.0 for downscaling) + * @return the index into the {@code downSampled} array to use, clamped to the + * maximum available level + */ + public int getDownscaleMipmapLevel(final double scale) { + double size = 1; + for (int i = 0; i < downSampled.length; i++) { + size = size / 2; + if (size < scale) + return i; + } + + return downSampled.length - 1; + } + + /** + * Determines the appropriate upscale mipmap level for a given scale factor. + * + *

Iterates through the upscaled mipmap levels (each doubling the size) + * and returns the index of the first level whose effective size exceeds + * the requested scale.

+ * + * @param scale the scale factor (typically greater than 2.0 for upscaling) + * @return the index into the {@code upSampled} array to use, or -1 if no + * upscale is needed or available + */ + public int getUpscaleMipmapLevel(final double scale) { + double size = 2; + for (int i = 0; i < upSampled.length; i++) { + size = size * 2; + if (size > scale) + return i; + } + + return -1; + } + + /** + * Downscale given bitmap by factor of 2. + * + * @param originalBitmap Bitmap to downscale. + * @return Downscaled bitmap. + */ + public TextureBitmap downscaleBitmap(final TextureBitmap originalBitmap) { + int newWidth = originalBitmap.width / 2; + int newHeight = originalBitmap.height / 2; + + // Enforce minimum width and height + if (newWidth < 1) + newWidth = 1; + if (newHeight < 1) + newHeight = 1; + + final TextureBitmap downScaled = new TextureBitmap(newWidth, newHeight, + originalBitmap.multiplicationFactor / 2d); + + final int[] srcPixels = originalBitmap.pixels; + final int[] dstPixels = downScaled.pixels; + final int srcW = originalBitmap.width; + final int srcH = originalBitmap.height; + final int srcWMinus1 = srcW - 1; + final int srcHMinus1 = srcH - 1; + + for (int y = 0; y < newHeight; y++) { + final int srcYBase = y * 2; + final int srcY1 = Math.min(srcYBase, srcHMinus1); + final int srcY2 = Math.min(srcYBase + 1, srcHMinus1); + final int row1Offset = srcY1 * srcW; + final int row2Offset = srcY2 * srcW; + + for (int x = 0; x < newWidth; x++) { + final int srcXBase = x * 2; + final int srcX1 = Math.min(srcXBase, srcWMinus1); + final int srcX2 = Math.min(srcXBase + 1, srcWMinus1); + + final int p0 = srcPixels[row1Offset + srcX1]; + final int p1 = srcPixels[row1Offset + srcX2]; + final int p2 = srcPixels[row2Offset + srcX1]; + final int p3 = srcPixels[row2Offset + srcX2]; + + final int a = (((p0 >>> 24) + (p1 >>> 24) + (p2 >>> 24) + (p3 >>> 24)) >> 2); + final int r = ((((p0 >> 16) & 0xff) + ((p1 >> 16) & 0xff) + ((p2 >> 16) & 0xff) + ((p3 >> 16) & 0xff)) >> 2); + final int g = ((((p0 >> 8) & 0xff) + ((p1 >> 8) & 0xff) + ((p2 >> 8) & 0xff) + ((p3 >> 8) & 0xff)) >> 2); + final int b = (((p0 & 0xff) + (p1 & 0xff) + (p2 & 0xff) + (p3 & 0xff)) >> 2); + + dstPixels[y * newWidth + x] = (a << 24) | (r << 16) | (g << 8) | b; + } + } + + return downScaled; + } + + /** + * Returns a downscaled bitmap at the specified mipmap level, creating it lazily if needed. + * + *

Level 0 is half the primary resolution, level 1 is a quarter, and so on. + * Each level is derived by downscaling the previous level by a factor of 2.

+ * + * @param scaleFactor the downscale level index (0 = 1/2 size, 1 = 1/4 size, etc.) + * @return the cached or newly created downscaled {@link TextureBitmap} + * @see #downscaleBitmap(TextureBitmap) + */ + public TextureBitmap getDownscaledBitmap(final int scaleFactor) { + if (downSampled[scaleFactor] == null) { + + TextureBitmap largerBitmap; + if (scaleFactor == 0) + largerBitmap = primaryBitmap; + else + largerBitmap = getDownscaledBitmap(scaleFactor - 1); + + downSampled[scaleFactor] = downscaleBitmap(largerBitmap); + } + + return downSampled[scaleFactor]; + } + + /** + * Returns the bitmap that should be used for rendering at the given zoom + * + * @param scaleFactor The upscale factor + * @return The bitmap + */ + public TextureBitmap getUpscaledBitmap(final int scaleFactor) { + if (upSampled[scaleFactor] == null) { + + TextureBitmap smallerBitmap; + if (scaleFactor == 0) + smallerBitmap = primaryBitmap; + else + smallerBitmap = getUpscaledBitmap(scaleFactor - 1); + + upSampled[scaleFactor] = upscaleBitmap(smallerBitmap); + } + + return upSampled[scaleFactor]; + } + + /** + * Returns the appropriate mipmap level for rendering at the given scale. + * + *

Scale factor represents how large the texture appears on screen + * relative to its native resolution:

+ *
    + *
  • scale < 1.0: texture appears smaller (use downscaled mipmap)
  • + *
  • scale 1.0-2.0: texture appears near native size (use primary bitmap)
  • + *
  • scale > 2.0: texture appears much larger (use upscaled mipmap)
  • + *
+ * + * @param scale the apparent scale factor of the texture on screen + * @return the best-fit mipmap level as a {@link TextureBitmap} + */ + public TextureBitmap getMipmapForScale(final double scale) { + + if (scale < 1) { + final int mipmapLevel = getDownscaleMipmapLevel(scale); + return getDownscaledBitmap(mipmapLevel); + } else if (scale > 2) { + final int mipmapLevel = getUpscaleMipmapLevel(scale); + + if (mipmapLevel < 0) + return primaryBitmap; + + return getUpscaledBitmap(mipmapLevel); + } + + return primaryBitmap; + } + + /** + * Resets the cache of resampled bitmaps + */ + public void resetResampledBitmapCache() { + fill(upSampled, null); + + fill(downSampled, null); + } + + /** + * Upscales the given bitmap by a factor of 2 + * + * @param originalBitmap The bitmap to upscale + * @return The upscaled bitmap + */ + public TextureBitmap upscaleBitmap(final TextureBitmap originalBitmap) { + final int srcW = originalBitmap.width; + final int srcH = originalBitmap.height; + final int newWidth = srcW * 2; + final int newHeight = srcH * 2; + final int srcWMinus1 = srcW - 1; + final int srcHMinus1 = srcH - 1; + + final TextureBitmap upScaled = new TextureBitmap(newWidth, newHeight, + originalBitmap.multiplicationFactor * 2d); + + final int[] src = originalBitmap.pixels; + final int[] dst = upScaled.pixels; + + for (int y = 0; y < srcH; y++) { + final int srcRowOffset = y * srcW; + final int nextRowOffset = Math.min(y + 1, srcHMinus1) * srcW; + final int dstRow0Offset = (y * 2) * newWidth; + final int dstRow1Offset = (y * 2 + 1) * newWidth; + + for (int x = 0; x < srcW; x++) { + final int nx = Math.min(x + 1, srcWMinus1); + + final int p00 = src[srcRowOffset + x]; + final int p10 = src[srcRowOffset + nx]; + final int p01 = src[nextRowOffset + x]; + final int p11 = src[nextRowOffset + nx]; + + dst[dstRow0Offset + x * 2] = p00; + dst[dstRow0Offset + x * 2 + 1] = avg2(p00, p10); + dst[dstRow1Offset + x * 2] = avg2(p00, p01); + dst[dstRow1Offset + x * 2 + 1] = avg4(p00, p10, p01, p11); + } + } + + return upScaled; + } + + private static int avg2(final int p0, final int p1) { + return (((((p0 >>> 24) + (p1 >>> 24)) >> 1) << 24) + | (((((p0 >> 16) & 0xff) + ((p1 >> 16) & 0xff)) >> 1) << 16) + | (((((p0 >> 8) & 0xff) + ((p1 >> 8) & 0xff)) >> 1) << 8) + | (((p0 & 0xff) + (p1 & 0xff)) >> 1)); + } + + private static int avg4(final int p0, final int p1, final int p2, final int p3) { + return ((((p0 >>> 24) + (p1 >>> 24) + (p2 >>> 24) + (p3 >>> 24)) >> 2) << 24) + | (((((p0 >> 16) & 0xff) + ((p1 >> 16) & 0xff) + ((p2 >> 16) & 0xff) + ((p3 >> 16) & 0xff)) >> 2) << 16) + | (((((p0 >> 8) & 0xff) + ((p1 >> 8) & 0xff) + ((p2 >> 8) & 0xff) + ((p3 >> 8) & 0xff)) >> 2) << 8) + | (((p0 & 0xff) + (p1 & 0xff) + (p2 & 0xff) + (p3 & 0xff)) >> 2); + } + + /** + * A helper class that accumulates color values for a given area of a bitmap. + */ + public static class ColorAccumulator { + /** Accumulated red component. */ + public int r; + /** Accumulated green component. */ + public int g; + /** Accumulated blue component. */ + public int b; + /** Accumulated alpha component. */ + public int a; + + /** Number of pixels accumulated. */ + public int pixelCount = 0; + + /** + * Creates a new color accumulator with zero values. + */ + public ColorAccumulator() { + } + + /** + * Accumulates the color values of the given pixel + * + * @param bitmap The bitmap + * @param x The x coordinate of the pixel + * @param y The y coordinate of the pixel + */ + public void accumulate(final TextureBitmap bitmap, final int x, + final int y) { + final int pixel = bitmap.pixels[bitmap.getAddress(x, y)]; + a += (pixel >> 24) & 0xff; + r += (pixel >> 16) & 0xff; + g += (pixel >> 8) & 0xff; + b += pixel & 0xff; + pixelCount++; + } + + /** + * Resets the accumulator + */ + public void reset() { + a = 0; + r = 0; + g = 0; + b = 0; + pixelCount = 0; + } + + /** + * Stores the accumulated color values in the given bitmap + * + * @param bitmap The bitmap + * @param x The x coordinate of the pixel + * @param y The y coordinate of the pixel + */ + public void storeResult(final TextureBitmap bitmap, final int x, + final int y) { + final int avgA = a / pixelCount; + final int avgR = r / pixelCount; + final int avgG = g / pixelCount; + final int avgB = b / pixelCount; + bitmap.pixels[bitmap.getAddress(x, y)] = (avgA << 24) | (avgR << 16) | (avgG << 8) | avgB; + } + } + +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureBitmap.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureBitmap.java new file mode 100644 index 0000000..8969225 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureBitmap.java @@ -0,0 +1,291 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.texture; + +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; + +/** + * Represents a single resolution level of a texture as a raw int array. + * + *

Each pixel is stored as a single int in ARGB format: + * {@code (alpha << 24) | (red << 16) | (green << 8) | blue}. + * This matches the {@link java.awt.image.BufferedImage#TYPE_INT_ARGB} format.

+ * + *

{@code TextureBitmap} is used internally by {@link Texture} to represent + * individual mipmap levels. The {@link #multiplicationFactor} records the + * scale ratio relative to the primary (native) resolution -- for example, + * a value of 0.5 means this bitmap is half the original size, and 2.0 + * means it is double.

+ * + *

This class provides low-level pixel operations including:

+ *
    + *
  • Alpha-blended pixel transfer to a target raster ({@link #drawPixel(int, int[], int)})
  • + *
  • Direct pixel writes using engine {@link Color} ({@link #drawPixel(int, int, Color)})
  • + *
  • Filled rectangle drawing ({@link #drawRectangle(int, int, int, int, Color)})
  • + *
  • Full-surface color fill ({@link #fillColor(Color)})
  • + *
+ * + * @see Texture + * @see Color + */ +public class TextureBitmap { + + /** + * Raw pixel data in ARGB int format. + * Each int encodes: {@code (alpha << 24) | (red << 16) | (green << 8) | blue}. + * The array length is {@code width * height}. + */ + public final int[] pixels; + + /** + * The width of this bitmap in pixels. + */ + public final int width; + + /** + * The height of this bitmap in pixels. + */ + public final int height; + + /** + * The scale factor of this bitmap relative to the primary (native) texture resolution. + * A value of 1.0 indicates the native resolution, 0.5 indicates half-size, 2.0 indicates double-size, etc. + */ + public double multiplicationFactor; + +/** + * Creates a texture bitmap backed by an existing int array. + * + *

This constructor is typically used when the bitmap data is obtained from + * a {@link java.awt.image.BufferedImage}'s raster, allowing direct access to + * the image's pixel data without copying.

+ * + * @param width the bitmap width in pixels + * @param height the bitmap height in pixels + * @param pixels the raw pixel data array (must be at least {@code width * height} ints) + * @param multiplicationFactor the scale factor relative to the native texture resolution + */ + public TextureBitmap(final int width, final int height, final int[] pixels, + final double multiplicationFactor) { + + this.width = width; + this.height = height; + this.pixels = pixels; + this.multiplicationFactor = multiplicationFactor; + } + + /** + * Creates a texture bitmap with a newly allocated int array. + * + *

The pixel data array is initialized to all zeros (fully transparent black).

+ * + * @param width the bitmap width in pixels + * @param height the bitmap height in pixels + * @param multiplicationFactor the scale factor relative to the native texture resolution + */ + public TextureBitmap(final int width, final int height, + final double multiplicationFactor) { + + this(width, height, new int[width * height], multiplicationFactor); + } + + /** + * Transfer (render) one pixel from current {@link TextureBitmap} to target RGB raster. + * + *

This texture stores pixels in ARGB format. The target is RGB format (no alpha). + * Alpha blending is performed based on the source pixel's alpha value.

+ * + *

Performance note: Uses bit-shift instead of division for alpha blending, + * and pre-multiplies source colors to reduce per-pixel operations.

+ * + * @param sourcePixelAddress Pixel index within current texture. + * @param targetBitmap Target RGB pixel array. + * @param targetPixelAddress Pixel index within target image. + */ + public void drawPixel(final int sourcePixelAddress, + final int[] targetBitmap, final int targetPixelAddress) { + + final int sourcePixel = pixels[sourcePixelAddress]; + final int textureAlpha = (sourcePixel >> 24) & 0xff; + + if (textureAlpha == 0) + return; + + if (textureAlpha == 255) { + targetBitmap[targetPixelAddress] = sourcePixel; + return; + } + + final int backgroundAlpha = 255 - textureAlpha; + + // Pre-multiply source colors by alpha to reduce operations in blend + final int srcR = ((sourcePixel >> 16) & 0xff) * textureAlpha; + final int srcG = ((sourcePixel >> 8) & 0xff) * textureAlpha; + final int srcB = (sourcePixel & 0xff) * textureAlpha; + + final int destPixel = targetBitmap[targetPixelAddress]; + final int destR = (destPixel >> 16) & 0xff; + final int destG = (destPixel >> 8) & 0xff; + final int destB = destPixel & 0xff; + + // Use bit-shift instead of division for faster blending + final int r = ((destR * backgroundAlpha) + srcR) >> 8; + final int g = ((destG * backgroundAlpha) + srcG) >> 8; + final int b = ((destB * backgroundAlpha) + srcB) >> 8; + + targetBitmap[targetPixelAddress] = (r << 16) | (g << 8) | b; + } + + /** + * Renders a scanline using pre-computed source pixel addresses. + * + *

This variant is optimized for cases where source addresses are computed + * externally (e.g., by a caller that already has the stepping logic). + * The sourceAddresses array must contain valid indices into {@link #pixels}.

+ * + * @param sourceAddresses array of source pixel addresses (indices into pixels array) + * @param targetBitmap target RGB pixel array + * @param targetStartAddress starting index in the target array + * @param pixelCount number of pixels to render + */ + public void drawScanlineWithAddresses(final int[] sourceAddresses, + final int[] targetBitmap, final int targetStartAddress, + final int pixelCount) { + + int targetOffset = targetStartAddress; + + for (int i = 0; i < pixelCount; i++) { + final int sourcePixel = pixels[sourceAddresses[i]]; + final int textureAlpha = (sourcePixel >> 24) & 0xff; + + if (textureAlpha == 255) { + targetBitmap[targetOffset] = sourcePixel; + } else if (textureAlpha != 0) { + final int backgroundAlpha = 255 - textureAlpha; + + final int srcR = ((sourcePixel >> 16) & 0xff) * textureAlpha; + final int srcG = ((sourcePixel >> 8) & 0xff) * textureAlpha; + final int srcB = (sourcePixel & 0xff) * textureAlpha; + + final int destPixel = targetBitmap[targetOffset]; + final int destR = (destPixel >> 16) & 0xff; + final int destG = (destPixel >> 8) & 0xff; + final int destB = destPixel & 0xff; + + final int r = ((destR * backgroundAlpha) + srcR) >> 8; + final int g = ((destG * backgroundAlpha) + srcG) >> 8; + final int b = ((destB * backgroundAlpha) + srcB) >> 8; + + targetBitmap[targetOffset] = (r << 16) | (g << 8) | b; + } + + targetOffset++; + } + } + + /** + * Draws a single pixel at the specified coordinates using the given color. + * + *

The color components are written directly without alpha blending. + * Coordinates are clamped to the bitmap bounds by {@link #getAddress(int, int)}.

+ * + * @param x the x coordinate of the pixel + * @param y the y coordinate of the pixel + * @param color the color to write + */ + public void drawPixel(final int x, final int y, final Color color) { + pixels[getAddress(x, y)] = (color.a << 24) | (color.r << 16) | (color.g << 8) | color.b; + } + + /** + * Fills a rectangular region with the specified color. + * + *

If {@code x1 > x2}, the coordinates are swapped to ensure correct rendering. + * The same applies to {@code y1} and {@code y2}. The rectangle is exclusive of the + * right and bottom edges.

+ * + *

Performance: Uses {@link java.util.Arrays#fill(int[], int, int, int)} + * per scanline for optimal JVM-optimized memory writes.

+ * + * @param x1 the left x coordinate + * @param y1 the top y coordinate + * @param x2 the right x coordinate (exclusive) + * @param y2 the bottom y coordinate (exclusive) + * @param color the fill color + */ + public void drawRectangle(int x1, int y1, int x2, int y2, + final Color color) { + + if (x1 > x2) { + final int tmp = x1; + x1 = x2; + x2 = tmp; + } + + if (y1 > y2) { + final int tmp = y1; + y1 = y2; + y2 = tmp; + } + + // Clamp to bitmap bounds + if (x1 < 0) x1 = 0; + if (y1 < 0) y1 = 0; + if (x2 > width) x2 = width; + if (y2 > height) y2 = height; + + final int pixel = (color.a << 24) | (color.r << 16) | (color.g << 8) | color.b; + final int rowWidth = x2 - x1; + + if (rowWidth <= 0) + return; + + // Fill each scanline using Arrays.fill for optimal performance + for (int y = y1; y < y2; y++) { + final int rowStart = y * width + x1; + java.util.Arrays.fill(pixels, rowStart, rowStart + rowWidth, pixel); + } + } + + /** + * Fills the entire bitmap with the specified color. + * + *

Every pixel in the bitmap is set to the given color value, + * overwriting all existing content.

+ * + * @param color the color to fill the entire bitmap with + */ + public void fillColor(final Color color) { + final int pixel = (color.a << 24) | (color.r << 16) | (color.g << 8) | color.b; + java.util.Arrays.fill(pixels, pixel); + } + + /** + * Computes the index into the {@link #pixels} array for the pixel at ({@code x}, {@code y}). + * + *

Coordinates are clamped to the valid range {@code [0, width-1]} and + * {@code [0, height-1]} so that out-of-bounds accesses are safely handled + * by sampling the nearest edge pixel.

+ * + * @param x the x coordinate of the pixel + * @param y the y coordinate of the pixel + * @return the index into the pixels array for the specified pixel + */ + public int getAddress(int x, int y) { + if (x < 0) + x = 0; + + if (x >= width) + x = width - 1; + + if (y < 0) + y = 0; + + if (y >= height) + y = height - 1; + + return (y * width) + x; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureGenerator.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureGenerator.java new file mode 100644 index 0000000..98ef154 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureGenerator.java @@ -0,0 +1,327 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.texture; + +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; + +import java.lang.ref.WeakReference; +import java.util.HashMap; +import java.util.Map; + +import static java.lang.Math.pow; +import static java.lang.Math.sqrt; + +/** + * Factory class for generating reusable textures with configurable borders and glow effects. + * + *

Provides static factory methods to create common texture patterns:

+ *
    + *
  • {@link #solidWithBorder} - solid fill color with opaque border (for bordered polygons)
  • + *
  • {@link #glowingBorder} - transparent center with glowing edges (for wireframe-effect shapes)
  • + *
  • {@link #radialGlow} - circular radial gradient (for point/billboard glows)
  • + *
+ * + *

Texture caching: Textures are cached by their generation parameters using a + * {@link WeakReference}-based cache. When textures are no longer referenced elsewhere, + * they are automatically garbage collected. This reduces memory usage when many shapes + * share identical textures.

+ * + *

Example usage:

+ *
{@code
+ * // RGB cube plate with black border
+ * Texture tex1 = TextureGenerator.solidWithBorder(64, new Color(255, 0, 0), new Color(0, 0, 0), 3);
+ *
+ * // Wireframe-effect texture (glowing cyan edges, transparent center)
+ * Texture tex2 = TextureGenerator.glowingBorder(64, new Color(0, 255, 255), 6, 120, true);
+ *
+ * // Circular glow point
+ * Texture tex3 = TextureGenerator.radialGlow(100, new Color(255, 200, 100));
+ * }
+ * + * @see Texture + * @see Color + */ +public final class TextureGenerator { + + /** + * Cache of generated textures, keyed by configuration parameters. + * Uses WeakReference so textures are GC'd when no longer referenced elsewhere. + */ + private static final Map> textureCache = new HashMap<>(); + + /** + * Private constructor to prevent instantiation. + * This class only provides static factory methods. + */ + private TextureGenerator() { + } + + /** + * Creates a texture with a solid fill color and an opaque border. + * + *

The fill color fills the entire texture except for the border region. + * The border is drawn as an opaque rectangle inset from the edges.

+ * + *

Caching: Identical parameters produce the same cached texture instance.

+ * + * @param size the texture width and height in pixels + * @param fillColor the color filling the interior + * @param borderColor the color of the border + * @param borderWidth the width of the border in pixels + * @param maxUpscale the maximum number of upscaled mipmap levels (0 = no upscale, 1 = 2x upscale, 2 = 4x upscale) + * @return a texture with solid fill and opaque border + */ + public static Texture solidWithBorder(final int size, final Color fillColor, + final Color borderColor, final int borderWidth, + final int maxUpscale) { + final TextureKey key = new TextureKey(size, fillColor, borderColor, borderWidth, + TextureType.SOLID_WITH_BORDER, 0, false, maxUpscale); + + return getOrCreate(key, () -> generateSolidWithBorder(size, fillColor, borderColor, borderWidth, maxUpscale)); + } + + /** + * Creates a texture with a transparent center and glowing border edges. + * + *

This texture is useful for creating wireframe-effect shapes: the polygon + * appears to have only edges, with the interior being transparent. The border + * has a glow effect where intensity decreases from the edge toward the center.

+ * + *

Glow effect: Multiple concentric border lines are drawn with decreasing + * alpha values, creating a luminous edge appearance. The glow intensity controls + * how bright the innermost edge appears.

+ * + *

Caching: Identical parameters produce the same cached texture instance.

+ * + * @param size the texture width and height in pixels + * @param borderColor the color of the glowing border + * @param borderWidth the width of the border region in pixels (where glow appears) + * @param glowIntensity the base intensity added to the border color (0-255 range) + * @param transparent if true, center is fully transparent; if false, has slight tint + * @param maxUpscale the maximum number of upscaled mipmap levels (0 = no upscale, 1 = 2x upscale, 2 = 4x upscale) + * @return a texture with glowing edges and transparent center + */ + public static Texture glowingBorder(final int size, final Color borderColor, + final int borderWidth, final int glowIntensity, + final boolean transparent, final int maxUpscale) { + final TextureKey key = new TextureKey(size, borderColor, Color.TRANSPARENT, borderWidth, + TextureType.GLOWING_BORDER, glowIntensity, transparent, maxUpscale); + + return getOrCreate(key, () -> generateGlowingBorder(size, borderColor, borderWidth, + glowIntensity, transparent, maxUpscale)); + } + + /** + * Creates a texture with a circular radial gradient glow. + * + *

The texture has a circular alpha gradient: fully opaque at the center, + * transitioning to fully transparent at the edges. The color intensity + * remains constant while alpha decreases radially.

+ * + *

This is suitable for rendering glowing points or circular billboards. + * The center of the texture is the brightest point, fading outward.

+ * + *

Caching: Identical parameters produce the same cached texture instance.

+ * + * @param size the texture width and height in pixels (should be even) + * @param color the color of the glow (alpha is overridden by radial gradient) + * @return a texture with circular radial alpha gradient + */ + public static Texture radialGlow(final int size, final Color color) { + final TextureKey key = new TextureKey(size, color, Color.TRANSPARENT, 0, + TextureType.RADIAL_GLOW, 0, false, 1); + + return getOrCreate(key, () -> generateRadialGlow(size, color)); + } + + /** + * Retrieves a cached texture or creates a new one if not cached. + * + * @param key the cache key identifying the texture configuration + * @param generator the function to create the texture if not cached + * @return the cached or newly created texture + */ + private static Texture getOrCreate(final TextureKey key, final TextureSupplier generator) { + synchronized (textureCache) { + final WeakReference ref = textureCache.get(key); + if (ref != null) { + final Texture cached = ref.get(); + if (cached != null) { + return cached; + } + // Reference was cleared, remove stale entry + textureCache.remove(key); + } + + final Texture texture = generator.create(); + textureCache.put(key, new WeakReference<>(texture)); + return texture; + } + } + + /** + * Generates a texture with solid fill and opaque border. + */ + private static Texture generateSolidWithBorder(final int size, final Color fillColor, + final Color borderColor, final int borderWidth, + final int maxUpscale) { + final Texture texture = new Texture(size, size, maxUpscale); + + // Fill interior with fill color + texture.primaryBitmap.drawRectangle(borderWidth, borderWidth, + size - borderWidth, size - borderWidth, fillColor); + + // Draw border regions (top, bottom, left, right edges) + // Top border + texture.primaryBitmap.drawRectangle(0, 0, size, borderWidth, borderColor); + // Bottom border + texture.primaryBitmap.drawRectangle(0, size - borderWidth, size, size, borderColor); + // Left border (excluding top/bottom corners already filled) + texture.primaryBitmap.drawRectangle(0, borderWidth, borderWidth, size - borderWidth, borderColor); + // Right border + texture.primaryBitmap.drawRectangle(size - borderWidth, borderWidth, size, size - borderWidth, borderColor); + + texture.resetResampledBitmapCache(); + return texture; + } + + /** + * Generates a texture with glowing border and transparent center. + */ + private static Texture generateGlowingBorder(final int size, final Color borderColor, + final int borderWidth, final int glowIntensity, + final boolean transparent, final int maxUpscale) { + final Texture texture = new Texture(size, size, maxUpscale); + + // Clear to transparent or slight tint + final int centerAlpha = transparent ? 0 : 30; + final java.awt.Color bgColor = new java.awt.Color(borderColor.r, borderColor.g, borderColor.b, centerAlpha); + texture.graphics.setBackground(bgColor); + texture.graphics.clearRect(0, 0, size, size); + + // Draw concentric glow lines from outer to inner + for (int i = 0; i < borderWidth; i++) { + final int intensity = (int) (glowIntensity * (borderWidth - i) / borderWidth); + final int alpha = Math.max(0, 200 - i * 30); + + final java.awt.Color glowColor = new java.awt.Color( + Math.min(255, borderColor.r + intensity), + Math.min(255, borderColor.g + intensity), + Math.min(255, borderColor.b + intensity), + alpha + ); + + texture.graphics.setColor(glowColor); + texture.graphics.drawRect(i, i, size - 1 - 2 * i, size - 1 - 2 * i); + } + + texture.graphics.dispose(); + texture.resetResampledBitmapCache(); + return texture; + } + + /** + * Generates a texture with circular radial alpha gradient. + */ + private static Texture generateRadialGlow(final int size, final Color color) { + final Texture texture = new Texture(size, size, 1); + final int halfSize = size / 2; + + for (int x = 0; x < size; x++) { + for (int y = 0; y < size; y++) { + final int distanceFromCenter = (int) sqrt(pow(halfSize - x, 2) + pow(halfSize - y, 2)); + + int alpha = 255 - ((270 * distanceFromCenter) / halfSize); + if (alpha < 0) { + alpha = 0; + } + + texture.primaryBitmap.pixels[texture.primaryBitmap.getAddress(x, y)] = + (alpha << 24) | (color.r << 16) | (color.g << 8) | color.b; + } + } + + texture.resetResampledBitmapCache(); + return texture; + } + + /** + * Functional interface for texture creation. + */ + @FunctionalInterface + private interface TextureSupplier { + Texture create(); + } + + /** + * Enumeration of texture generation types for cache key differentiation. + */ + private enum TextureType { + SOLID_WITH_BORDER, + GLOWING_BORDER, + RADIAL_GLOW + } + + /** + * Cache key for texture lookup based on generation parameters. + * + *

Two textures with identical parameters should produce the same key, + * enabling cache reuse. The key includes all parameters that affect + * the visual appearance of the generated texture.

+ */ + private static final class TextureKey { + private final int size; + private final Color primaryColor; + private final Color secondaryColor; + private final int borderWidth; + private final TextureType type; + private final int glowIntensity; + private final boolean transparent; + private final int maxUpscale; + + TextureKey(final int size, final Color primaryColor, final Color secondaryColor, + final int borderWidth, final TextureType type, final int glowIntensity, + final boolean transparent, final int maxUpscale) { + this.size = size; + this.primaryColor = primaryColor; + this.secondaryColor = secondaryColor; + this.borderWidth = borderWidth; + this.type = type; + this.glowIntensity = glowIntensity; + this.transparent = transparent; + this.maxUpscale = maxUpscale; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + final TextureKey that = (TextureKey) o; + + if (size != that.size) return false; + if (borderWidth != that.borderWidth) return false; + if (glowIntensity != that.glowIntensity) return false; + if (transparent != that.transparent) return false; + if (maxUpscale != that.maxUpscale) return false; + if (type != that.type) return false; + if (!primaryColor.equals(that.primaryColor)) return false; + return secondaryColor.equals(that.secondaryColor); + } + + @Override + public int hashCode() { + int result = size; + result = 31 * result + primaryColor.hashCode(); + result = 31 * result + secondaryColor.hashCode(); + result = 31 * result + borderWidth; + result = 31 * result + type.hashCode(); + result = 31 * result + glowIntensity; + result = 31 * result + (transparent ? 1 : 0); + result = 31 * result + maxUpscale; + return result; + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/package-info.java new file mode 100644 index 0000000..d319ae7 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/package-info.java @@ -0,0 +1,22 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Texture support with mipmap chains for level-of-detail rendering. + * + *

Textures provide 2D image data that can be mapped onto polygons. The mipmap + * system automatically generates scaled versions for efficient rendering at + * various distances.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture} - Main texture class with mipmap support
  • + *
  • {@link eu.svjatoslav.aukio.e3d.renderer.raster.texture.TextureBitmap} - Raw pixel data for a single mipmap level
  • + *
+ * + * @see eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture + */ + +package eu.svjatoslav.aukio.e3d.renderer.raster.texture; \ No newline at end of file diff --git a/src/main/resources/eu/svjatoslav/aukio/e3d/examples/hourglass.png b/src/main/resources/eu/svjatoslav/aukio/e3d/examples/hourglass.png new file mode 100644 index 0000000..47a1638 Binary files /dev/null and b/src/main/resources/eu/svjatoslav/aukio/e3d/examples/hourglass.png differ diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextLineTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextLineTest.java new file mode 100644 index 0000000..593fc41 --- /dev/null +++ b/src/test/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextLineTest.java @@ -0,0 +1,116 @@ +/* + * Aukio - System for data storage, computation, exploration and interaction. + * Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + * +*/ + +package eu.svjatoslav.aukio.e3d.gui.textEditorComponent; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class TextLineTest { + + @Test + public void testAddIndent() { + TextLine textLine = new TextLine("test"); + textLine.addIndent(4); + assertEquals(" test", textLine.toString()); + + textLine = new TextLine(); + textLine.addIndent(4); + assertEquals("", textLine.toString()); + } + + @Test + public void testCutFromBeginning() { + TextLine textLine = new TextLine("test"); + textLine.cutFromBeginning(2); + assertEquals("st", textLine.toString()); + + textLine = new TextLine("test"); + textLine.cutFromBeginning(4); + assertEquals("", textLine.toString()); + + textLine = new TextLine("test"); + textLine.cutFromBeginning(5); + assertEquals("", textLine.toString()); + + textLine = new TextLine("test"); + textLine.cutFromBeginning(100); + assertEquals("", textLine.toString()); + } + + @Test + public void testCutSubString() { + TextLine textLine = new TextLine("test"); + assertEquals("es", textLine.cutSubString(1, 3)); + assertEquals("tt", textLine.toString()); + + textLine = new TextLine("test"); + assertEquals("st ", textLine.cutSubString(2, 5)); + assertEquals("te", textLine.toString()); + } + + @Test + public void testGetCharForLocation() { + final TextLine textLine = new TextLine("test"); + assertEquals('s', textLine.getCharForLocation(2)); + assertEquals('t', textLine.getCharForLocation(3)); + assertEquals(' ', textLine.getCharForLocation(4)); + } + + @Test + public void testGetIndent() { + final TextLine textLine = new TextLine(" test"); + assertEquals(3, textLine.getIndent()); + } + + @Test + public void testGetLength() { + final TextLine textLine = new TextLine("test"); + assertEquals(4, textLine.getLength()); + } + + @Test + public void testInsertCharacter() { + TextLine textLine = new TextLine("test"); + textLine.insertCharacter(1, 'o'); + assertEquals("toest", textLine.toString()); + + textLine = new TextLine("test"); + textLine.insertCharacter(5, 'o'); + assertEquals("test o", textLine.toString()); + + } + + @Test + public void testIsEmpty() { + TextLine textLine = new TextLine(""); + assertEquals(true, textLine.isEmpty()); + + textLine = new TextLine(" "); + assertEquals(true, textLine.isEmpty()); + + textLine = new TextLine("l"); + assertEquals(false, textLine.isEmpty()); + } + + @Test + public void testRemoveCharacter() { + TextLine textLine = new TextLine("test"); + textLine.removeCharacter(0); + assertEquals("est", textLine.toString()); + + textLine = new TextLine("test"); + textLine.removeCharacter(3); + assertEquals("tes", textLine.toString()); + + textLine = new TextLine("test"); + textLine.removeCharacter(4); + assertEquals("test", textLine.toString()); + } + +} diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/package-info.java b/src/test/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/package-info.java new file mode 100644 index 0000000..dfcdecd --- /dev/null +++ b/src/test/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/package-info.java @@ -0,0 +1,13 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Unit tests for the text editor component. + * + *

Tests for {@link eu.svjatoslav.aukio.e3d.gui.textEditorComponent.TextLine} + * and related text processing functionality.

+ */ + +package eu.svjatoslav.aukio.e3d.gui.textEditorComponent; \ No newline at end of file diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/headless/HeadlessToolkitTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/headless/HeadlessToolkitTest.java new file mode 100644 index 0000000..c648bdd --- /dev/null +++ b/src/test/java/eu/svjatoslav/aukio/e3d/headless/HeadlessToolkitTest.java @@ -0,0 +1,85 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.headless; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.Camera; +import eu.svjatoslav.aukio.e3d.renderer.raster.Color; +import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; +import org.junit.Test; + +import java.awt.image.BufferedImage; +import java.io.File; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Verifies the headless toolkit: pose parsing round-trip, snapshot + * rendering actually paints, pixel assertions agree with the render, + * golden comparison passes/fails deterministically. + */ +public class HeadlessToolkitTest { + + /** A red triangle 100 units in front of the origin-facing camera. */ + private static ShapeCollection triangleScene() { + final ShapeCollection scene = new ShapeCollection(); + scene.addShape(new SolidPolygon( + new Point3D(-100, 0, 100), new Point3D(100, 0, 100), + new Point3D(0, 100, 100), Color.RED)); + return scene; + } + + @Test + public void poseRoundTrip() { + final Camera camera = Snapshot.cameraFromPose("290.31, -35.59, -2.10, -0.58, -0.15, -0.00"); + final String pose = Snapshot.poseString(camera); + // parsed back, the pose string must be identical (same 2-decimal precision) + assertEquals("290.31, -35.59, -2.10, -0.58, -0.15, -0.00", pose); + } + + @Test + public void renderPaintsTriangle() { + final BufferedImage image = Snapshot.render(triangleScene(), null, + "0, 0, 0, 0, 0, 0", 320, 240); + final long red = PixelAssertions.countColor(image, 0xFF0000); + assertTrue("red triangle should paint, redPixels=" + red, red > 5000); + assertTrue("most of the frame stays background", + PixelAssertions.unpaintedFraction(image, 0) > 0.5); + } + + @Test + public void unpaintedFractionDetectsRegion() { + final BufferedImage image = Snapshot.render(triangleScene(), null, + "0, 0, 0, 0, 0, 0", 320, 240); + // the triangle is centered; corners must be unpainted + final double cornerBand = PixelAssertions.unpaintedFraction(image, 0, 0, 0, 0.1, 0.1); + assertEquals(1.0, cornerBand, 0.001); + } + + @Test + public void goldenCompareExact() throws Exception { + final BufferedImage image = Snapshot.render(triangleScene(), null, + "0, 0, 0, 0, 0, 0", 320, 240); + final File golden = File.createTempFile("golden", ".png"); + golden.deleteOnExit(); + Snapshot.save(image, golden.getAbsolutePath()); + + final GoldenImage.Result exact = GoldenImage.compare(image, golden, 0, 0.0); + assertTrue(exact.toString(), exact.passed); + assertEquals(0, exact.diffPixels); + + // paint one pixel differently: comparison must now fail at tolerance 0 + image.setRGB(10, 10, 0x00FF00); + final GoldenImage.Result drifted = GoldenImage.compare(image, golden, 0, 0.0); + assertTrue(!drifted.passed); + assertEquals(1, drifted.diffPixels); + + // ... but pass when one differing pixel is within the allowed fraction + final GoldenImage.Result tolerated = GoldenImage.compare(image, golden, 0, 0.001); + assertTrue(tolerated.toString(), tolerated.passed); + } +} diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/math/QuaternionTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/math/QuaternionTest.java new file mode 100644 index 0000000..8745219 --- /dev/null +++ b/src/test/java/eu/svjatoslav/aukio/e3d/math/QuaternionTest.java @@ -0,0 +1,59 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.math; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class QuaternionTest { + + @Test + public void testFromAnglesProducesValidMatrix() { + final Quaternion quaternion = Quaternion.fromAngles(0.5, 0.3); + final Matrix3x3 matrix = quaternion.toMatrix(); + + // Verify matrix is a valid rotation (determinant ≈ 1) + final double det = matrix.m00 * (matrix.m11 * matrix.m22 - matrix.m12 * matrix.m21) + - matrix.m01 * (matrix.m10 * matrix.m22 - matrix.m12 * matrix.m20) + + matrix.m02 * (matrix.m10 * matrix.m21 - matrix.m11 * matrix.m20); + assertEquals(1.0, det, 0.0001); + } + + @Test + public void testToMatrixAliasesToMatrix3x3() { + final Quaternion quaternion = Quaternion.fromAngles(0.7, -0.4); + final Matrix3x3 m1 = quaternion.toMatrix(); + final Matrix3x3 m2 = quaternion.toMatrix3x3(); + + final double epsilon = 0.0001; + assertEquals(m1.m00, m2.m00, epsilon); + assertEquals(m1.m01, m2.m01, epsilon); + assertEquals(m1.m02, m2.m02, epsilon); + assertEquals(m1.m10, m2.m10, epsilon); + assertEquals(m1.m11, m2.m11, epsilon); + assertEquals(m1.m12, m2.m12, epsilon); + assertEquals(m1.m20, m2.m20, epsilon); + assertEquals(m1.m21, m2.m21, epsilon); + assertEquals(m1.m22, m2.m22, epsilon); + } + + @Test + public void testCloneProducesIndependentCopy() { + final Quaternion original = Quaternion.fromAngles(0.5, 0.3); + final Quaternion clone = original.clone(); + + assertEquals(original.w, clone.w, 0.0001); + assertEquals(original.x, clone.x, 0.0001); + assertEquals(original.y, clone.y, 0.0001); + assertEquals(original.z, clone.z, 0.0001); + + // Modify original, verify clone is unaffected + final double originalW = original.w; + original.w = 0; + assertEquals(originalW, clone.w, 0.0001); + } + +} \ No newline at end of file diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/math/TransformStackTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/math/TransformStackTest.java new file mode 100644 index 0000000..ed5275b --- /dev/null +++ b/src/test/java/eu/svjatoslav/aukio/e3d/math/TransformStackTest.java @@ -0,0 +1,139 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.math; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import org.junit.Test; + +import java.util.Random; + +import static org.junit.Assert.assertEquals; + +public class TransformStackTest { + + private static final double EPSILON = 1e-6; + + @Test + public void transformWithEmptyStackIsIdentity() { + final TransformStack stack = new TransformStack(); + final Point3D p = new Point3D(10, 20, 30); + final Point3D result = new Point3D(); + + stack.transform(p, result); + + assertEquals(p.x, result.x, EPSILON); + assertEquals(p.y, result.y, EPSILON); + assertEquals(p.z, result.z, EPSILON); + } + + @Test + public void transformMatchesSequentialApplication() { + final Random rnd = new Random(42); + + for (int depth = 1; depth <= 8; depth++) { + final Transform[] chain = new Transform[depth]; + final TransformStack stack = new TransformStack(); + for (int i = 0; i < depth; i++) { + chain[i] = Transform.fromAngles( + (rnd.nextDouble() - 0.5) * 1000, + (rnd.nextDouble() - 0.5) * 1000, + (rnd.nextDouble() - 0.5) * 1000, + (rnd.nextDouble() - 0.5) * Math.PI * 2, + (rnd.nextDouble() - 0.5) * Math.PI, + (rnd.nextDouble() - 0.5) * Math.PI); + stack.addTransform(chain[i]); + } + + for (int k = 0; k < 100; k++) { + final Point3D p = new Point3D( + (rnd.nextDouble() - 0.5) * 2000, + (rnd.nextDouble() - 0.5) * 2000, + (rnd.nextDouble() - 0.5) * 2000); + + // Oracle: documented semantics — transforms applied in reverse + // order of insertion (last added = first applied) + final Point3D expected = new Point3D(p); + for (int i = depth - 1; i >= 0; i--) { + chain[i].transform(expected); + } + + final Point3D result = new Point3D(); + stack.transform(p, result); + + assertEquals("depth " + depth + " x", expected.x, result.x, EPSILON); + assertEquals("depth " + depth + " y", expected.y, result.y, EPSILON); + assertEquals("depth " + depth + " z", expected.z, result.z, EPSILON); + } + } + } + + @Test + public void dropTransformRestoresParentState() { + final Transform a = Transform.fromAngles(100, 0, 0, 0.3, 0.1, 0); + final Transform b = Transform.fromAngles(0, 50, 0, 0, 0.5, 0.2); + final Transform c = Transform.fromAngles(0, 0, 500, 1.0, 0, 0.4); + + final TransformStack stack = new TransformStack(); + stack.addTransform(a); + stack.addTransform(b); + stack.dropTransform(); + stack.addTransform(c); + + final TransformStack reference = new TransformStack(); + reference.addTransform(a); + reference.addTransform(c); + + final Point3D p = new Point3D(7, -13, 42); + final Point3D result = new Point3D(); + final Point3D expected = new Point3D(); + stack.transform(p, result); + reference.transform(p, expected); + + assertEquals(expected.x, result.x, EPSILON); + assertEquals(expected.y, result.y, EPSILON); + assertEquals(expected.z, result.z, EPSILON); + } + + @Test + public void transformComposesEagerlyAtPushTime() { + final Transform transform = Transform.fromAngles(10, 20, 30, 0.5, 0.2, 0.1); + + final TransformStack stack = new TransformStack(); + stack.addTransform(transform); + + // Expected result uses the values the transform had when pushed + final Point3D p = new Point3D(1, 2, 3); + final Point3D expected = new Point3D(p); + final Transform snapshot = transform.clone(); + snapshot.transform(expected); + + // Mutating the transform after pushing must NOT affect the stack: + // composition is an eager snapshot taken at push time + transform.set(-999, 888, -777, 2.5, -1.5, 0.9); + + final Point3D result = new Point3D(); + stack.transform(p, result); + + assertEquals(expected.x, result.x, EPSILON); + assertEquals(expected.y, result.y, EPSILON); + assertEquals(expected.z, result.z, EPSILON); + } + + @Test + public void clearResetsStackToIdentity() { + final TransformStack stack = new TransformStack(); + stack.addTransform(Transform.fromAngles(1, 2, 3, 0.5, 0.2, 0.1)); + stack.addTransform(Transform.fromAngles(4, 5, 6, 0.1, 0.9, 0.3)); + stack.clear(); + + final Point3D p = new Point3D(10, 20, 30); + final Point3D result = new Point3D(); + stack.transform(p, result); + + assertEquals(p.x, result.x, EPSILON); + assertEquals(p.y, result.y, EPSILON); + assertEquals(p.z, result.z, EPSILON); + } +} diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/ParallelTransformTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/ParallelTransformTest.java new file mode 100644 index 0000000..0d6fea0 --- /dev/null +++ b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/ParallelTransformTest.java @@ -0,0 +1,286 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster; + +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; +import eu.svjatoslav.aukio.e3d.math.Transform; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCube; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonSphere; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.WireframeCube; +import org.junit.After; +import org.junit.Test; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import eu.svjatoslav.aukio.e3d.math.TransformStack; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Verifies that the parallel transform+sort pipeline produces exactly the + * same render queue as the serial pipeline: same shapes, same order, same + * culling decisions. + */ +public class ParallelTransformTest { + + private static final int W = 1280, H = 720; + private static final double EPSILON = 1e-9; + + private ExecutorService executor; + + @After + public void tearDown() { + if (executor != null) { + executor.shutdownNow(); + } + } + + private ShapeCollection buildScene(final ViewPanel panel) { + final ShapeCollection scene = panel.getRootShapeCollection(); + panel.getCamera().getTransform().setTranslation(new Point3D(0, 0, -600)); + + // Top-level spheres: each expands to hundreds of triangles internally + for (int i = 0; i < 40; i++) { + scene.addShape(new SolidPolygonSphere( + new Point3D((i % 8 - 4) * 60, (i / 8 - 2) * 60, 400), + 25, 12, Color.GREEN)); + } + + // Many cheap wireframe cubes -> enough top-level items to fork on + final LineAppearance appearance = new LineAppearance(2.0, Color.CYAN); + for (int i = 0; i < 200; i++) { + scene.addShape(new WireframeCube( + new Point3D((i % 20 - 10) * 40, (i / 20 - 5) * 40, 700), + 15, appearance)); + } + + // Nested composite with its own transform -> transform stacking + final AbstractCompositeShape nested = new AbstractCompositeShape(new Point3D(50, -50, 500)); + nested.setTransform(Transform.fromAngles(50, -50, 500, 0.3, 0.2, 0.1)); + nested.addShape(new SolidPolygonCube(new Point3D(0, 0, 0), 30, Color.RED)); + nested.addShape(new SolidPolygonSphere(new Point3D(80, 0, 40), 20, 10, Color.BLUE)); + scene.addShape(nested); + + // Composite entirely behind the camera -> frustum culling inside workers + final AbstractCompositeShape offscreen = new AbstractCompositeShape(new Point3D(0, 0, -5000)); + offscreen.addShape(new SolidPolygonCube(new Point3D(0, 0, 0), 30, Color.RED)); + scene.addShape(offscreen); + + // Quad -> N-gon triangulation path + scene.addShape(SolidPolygon.quad( + new Point3D(-100, -100, 300), new Point3D(100, -100, 300), + new Point3D(100, 100, 300), new Point3D(-100, 100, 300), + Color.WHITE)); + + return scene; + } + + private int[] snapshotIds(final ShapeCollection scene) { + final List queue = scene.getQueuedShapes(); + final int[] ids = new int[queue.size()]; + for (int i = 0; i < ids.length; i++) { + ids[i] = queue.get(i).shapeId; + } + return ids; + } + + private double[] snapshotZs(final ShapeCollection scene) { + final List queue = scene.getQueuedShapes(); + final double[] zs = new double[queue.size()]; + for (int i = 0; i < zs.length; i++) { + zs[i] = queue.get(i).getZ(0); + } + return zs; + } + + @Test + public void parallelTransformMatchesSerialPipeline() { + System.setProperty("java.awt.headless", "true"); + final ViewPanel panel = new ViewPanel(); + final ShapeCollection scene = buildScene(panel); + + // Serial run: no executor -> serial fallback path + final RenderingContext serialCtx = new RenderingContext(W, H, 1); + serialCtx.prepareForNewFrameRendering(); + scene.transformShapes(panel, serialCtx); + scene.sortShapes(); + final int[] serialIds = snapshotIds(scene); + final double[] serialZs = snapshotZs(scene); + final int serialTotal = serialCtx.cullingStatistics.totalComposites.get(); + final int serialCulled = serialCtx.cullingStatistics.culledComposites.get(); + + // Parallel run: executor set -> forked traversal + executor = Executors.newFixedThreadPool(8); + final RenderingContext parallelCtx = new RenderingContext(W, H, 1); + parallelCtx.transformExecutor = executor; + parallelCtx.prepareForNewFrameRendering(); + parallelCtx.prepareForNewFrameRendering(); // distinct frameNumber -> full re-transform + scene.transformShapes(panel, parallelCtx); + scene.sortShapes(); + final int[] parallelIds = snapshotIds(scene); + final double[] parallelZs = snapshotZs(scene); + + // Same render queue, same order (sort is deterministic: Z then shapeId) + assertEquals("queued shape count", serialIds.length, parallelIds.length); + assertTrue("scene must be big enough to exercise parallel paths", + serialIds.length > 8192); + for (int i = 0; i < serialIds.length; i++) { + assertEquals("shapeId at position " + i, serialIds[i], parallelIds[i]); + assertEquals("Z at position " + i, serialZs[i], parallelZs[i], EPSILON); + } + + // Same culling decisions (and thread-safe counters) + assertEquals(serialTotal, parallelCtx.cullingStatistics.totalComposites.get()); + assertEquals(serialCulled, parallelCtx.cullingStatistics.culledComposites.get()); + assertTrue("offscreen composite must be culled", serialCulled >= 1); + + // Sortedness property: Z descending, shapeId ascending tiebreak. + // Tiebreak comparison must be exact, matching the comparator's + // double semantics — nearly-equal Z values are NOT a tie. + for (int i = 1; i < parallelIds.length; i++) { + assertTrue("Z order at " + i, parallelZs[i - 1] >= parallelZs[i]); + if (parallelZs[i - 1] == parallelZs[i]) { + assertTrue("shapeId tiebreak at " + i, parallelIds[i - 1] < parallelIds[i]); + } + } + } + + @Test + public void nestedHeavyCompositeForksInternally() { + System.setProperty("java.awt.headless", "true"); + final ViewPanel panel = new ViewPanel(); + final ShapeCollection scene = panel.getRootShapeCollection(); + panel.getCamera().getTransform().setTranslation(new Point3D(0, 0, -600)); + + // Few root children: root stays below the parallel threshold and + // transforms serially, so any fork must come from the nested level + scene.addShape(new SolidPolygonSphere(new Point3D(-200, 0, 400), 25, 10, Color.GREEN)); + scene.addShape(new SolidPolygonSphere(new Point3D(200, 0, 400), 25, 10, Color.RED)); + + // One outsized nested composite, well above the fork threshold + final AbstractCompositeShape giant = new AbstractCompositeShape(new Point3D(0, 0, 300)); + final LineAppearance appearance = new LineAppearance(2.0, Color.CYAN); + for (int i = 0; i < 200; i++) { + giant.addShape(new WireframeCube( + new Point3D((i % 20 - 10) * 40, (i / 20 - 5) * 40, 200), + 15, appearance)); + } + scene.addShape(giant); + + // Serial run + final RenderingContext serialCtx = new RenderingContext(W, H, 1); + serialCtx.prepareForNewFrameRendering(); + scene.transformShapes(panel, serialCtx); + scene.sortShapes(); + final int[] serialIds = snapshotIds(scene); + final double[] serialZs = snapshotZs(scene); + + // Parallel run + executor = Executors.newFixedThreadPool(8); + final RenderingContext parallelCtx = new RenderingContext(W, H, 1); + parallelCtx.transformExecutor = executor; + parallelCtx.prepareForNewFrameRendering(); + parallelCtx.prepareForNewFrameRendering(); + scene.transformShapes(panel, parallelCtx); + scene.sortShapes(); + final int[] parallelIds = snapshotIds(scene); + final double[] parallelZs = snapshotZs(scene); + + // The nested composite must have forked: root has only 3 children + // (below the threshold), so all chunk tasks are nested-level + assertTrue("nested composite must fork its own children", + parallelCtx.lastTransformTaskCount >= 2); + + // Identical render queue + assertEquals("queued shape count", serialIds.length, parallelIds.length); + assertTrue("scene must be big enough to exercise the fork", + serialIds.length > 1000); + for (int i = 0; i < serialIds.length; i++) { + assertEquals("shapeId at position " + i, serialIds[i], parallelIds[i]); + assertEquals("Z at position " + i, serialZs[i], parallelZs[i], EPSILON); + } + } + + @Test + public void renderListRebuildDuringInFlightChunkTasksUsesSnapshottedRenderList() throws Exception { + System.setProperty("java.awt.headless", "true"); + + // One composite with many children: 4 permanent + 4096 in a group + // that will be hidden mid-flight to force a render-list rebuild that + // REASSIGNS cachedRenderList to a much smaller list. + final AbstractCompositeShape composite = + new AbstractCompositeShape(new Point3D(0, 0, 400)); + composite.setRootComposite(true); // skip frustum culling entirely + for (int i = 0; i < 4; i++) { + composite.addShape(SolidPolygon.triangle( + new Point3D(i * 10, 0, 0), new Point3D(i * 10 + 5, 0, 0), + new Point3D(i * 10, 5, 0), Color.GREEN), "keep"); + } + for (int i = 0; i < 4096; i++) { + composite.addShape(SolidPolygon.triangle( + new Point3D(i % 64, i / 64, 0), new Point3D(i % 64 + 1, i / 64, 0), + new Point3D(i % 64, i / 64 + 1, 0), Color.RED), "bulk"); + } + + executor = Executors.newFixedThreadPool(4); + + // Occupy every pool thread: the chunk tasks submitted below queue + // up but cannot start, reproducing the pipeline state where the + // NEXT pass's tree walk begins while this pass's chunks are + // still pending (the drain runs in the async continuation). + final CountDownLatch blockersStarted = new CountDownLatch(4); + final CountDownLatch releaseBlockers = new CountDownLatch(1); + for (int i = 0; i < 4; i++) { + executor.submit(() -> { + blockersStarted.countDown(); + try { + releaseBlockers.await(30, TimeUnit.SECONDS); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + assertTrue("pool blockers must be running", + blockersStarted.await(30, TimeUnit.SECONDS)); + + // Pass P: forks chunk tasks against the 4100-entry render list. + final ParallelTransformCoordinator coordinator = + new ParallelTransformCoordinator(executor); + final RenderingContext passContext = new RenderingContext(W, H, 1); + passContext.prepareForNewFrameRendering(); + passContext.transformCoordinator = coordinator; + final RenderAggregator aggregator = new RenderAggregator(); + composite.transform(new TransformStack(), aggregator, passContext); + assertTrue("composite must have forked chunk tasks", + coordinator.getSubmittedTaskCount() >= 2); + + // Pass P+1's tree walk arrives while P's chunk tasks are still + // queued: hiding "bulk" forces a render-list rebuild that reassigns + // cachedRenderList to a 4-entry list. (No coordinator on this + // context -> serial path, itself immune to the race.) + composite.hideGroup("bulk"); + final RenderingContext nextContext = new RenderingContext(W, H, 1); + nextContext.prepareForNewFrameRendering(); + composite.transform(new TransformStack(), new RenderAggregator(), nextContext); + + // Now P's chunk tasks run. They must iterate the SAME list + // instance their chunk ranges were computed against — re-reading + // the reassigned field threw IndexOutOfBoundsException (observed + // in production: "Index 258 out of bounds for length 24"). + releaseBlockers.countDown(); + coordinator.drainAndMergeInto(aggregator); + } +} diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/RadixLongSortTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/RadixLongSortTest.java new file mode 100644 index 0000000..56180c0 --- /dev/null +++ b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/RadixLongSortTest.java @@ -0,0 +1,95 @@ +package eu.svjatoslav.aukio.e3d.renderer.raster; + +import org.junit.Test; + +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Verifies the radix sort core: key mapping reproduces the Z-comparator's + * ordering semantics, and the pair sort is sorted and stable. + */ +public class RadixLongSortTest { + + /** The comparator's Z comparison: -1/0/+1 with NaN treated as "equal". */ + private static int compareZ(final double z1, final double z2) { + if (z1 < z2) + return 1; // painter order: larger z first + if (z1 > z2) + return -1; + return 0; + } + + @Test + public void keyOrderMatchesComparator() { + final double[] specials = { + 0.0d, -0.0d, 1.0d, -1.0d, 5e-3d, 96.0d, 1e6d, + Double.MIN_VALUE, -Double.MIN_VALUE, + Double.MAX_VALUE, -Double.MAX_VALUE, + Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, + 123456.789d, -123456.789d + }; + final Random random = new Random(42); + final double[] values = new double[specials.length + 500]; + System.arraycopy(specials, 0, values, 0, specials.length); + for (int i = specials.length; i < values.length; i++) + values[i] = (random.nextDouble() - 0.5) * 2e6; + for (final double z1 : values) + for (final double z2 : values) { + final int expected = compareZ(z1, z2); + final int actual = Long.compareUnsigned( + RadixLongSort.zSortKey(z1), RadixLongSort.zSortKey(z2)); + assertTrue("z1=" + z1 + " z2=" + z2 + " expected sign " + + expected + " got " + actual, + Integer.signum(expected) == Integer.signum(actual)); + } + } + + @Test + public void minusZeroAndPlusZeroShareOneKey() { + assertEquals(RadixLongSort.zSortKey(0.0d), + RadixLongSort.zSortKey(-0.0d)); + } + + @Test + public void sortPairsIsSortedAndStable() { + final Random random = new Random(7); + for (final int n : new int[]{0, 1, 2, 100, 10000}) { + final long[] keys = new long[Math.max(n, 1)]; + final long[] keysTmp = new long[Math.max(n, 1)]; + final int[] idx = new int[Math.max(n, 1)]; + final int[] idxTmp = new int[Math.max(n, 1)]; + // Duplicate-heavy keys exercise stability + for (int i = 0; i < n; i++) { + keys[i] = random.nextInt(50); + idx[i] = i; + } + RadixLongSort.sortPairs(keys, idx, n, keysTmp, idxTmp); + for (int i = 1; i < n; i++) { + assertTrue("sorted at " + i, + Long.compareUnsigned(keys[i - 1], keys[i]) <= 0); + if (keys[i - 1] == keys[i]) + assertTrue("stable at " + i, idx[i - 1] < idx[i]); + } + } + } + + @Test + public void sortPairsHandlesFullUnsignedRange() { + final Random random = new Random(99); + final int n = 5000; + final long[] keys = new long[n]; + final long[] keysTmp = new long[n]; + final int[] idx = new int[n]; + final int[] idxTmp = new int[n]; + for (int i = 0; i < n; i++) { + keys[i] = random.nextLong(); // full range incl. "negative" + idx[i] = i; + } + RadixLongSort.sortPairs(keys, idx, n, keysTmp, idxTmp); + for (int i = 1; i < n; i++) + assertTrue(Long.compareUnsigned(keys[i - 1], keys[i]) <= 0); + } +} diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/SegmentBinningTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/SegmentBinningTest.java new file mode 100644 index 0000000..ef7c9af --- /dev/null +++ b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/SegmentBinningTest.java @@ -0,0 +1,286 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.gui.SegmentRenderingContext; +import eu.svjatoslav.aukio.e3d.gui.ViewPanel; +import eu.svjatoslav.aukio.e3d.math.Transform; +import eu.svjatoslav.aukio.e3d.math.Vertex; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.GlowingPoint; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCube; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonSphere; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas; +import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.WireframeCube; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.TextureGenerator; +import org.junit.After; +import org.junit.Test; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Verifies that paint tile binning produces pixel-identical output to + * painting the full sorted queue in every tile: binning must only skip + * shapes that cannot touch a tile's rectangle, never shapes that can. + * + *

The scene deliberately includes shapes whose paint output extends + * past their vertex bounds on both axes (thick lines, glowing point + * billboards, text glyphs) to exercise the per-shape X/Y margins.

+ */ +public class SegmentBinningTest { + + private static final int W = 1280, H = 720; + private static final int TILES_X = 4, TILES_Y = 3; + private static final int TILE_COUNT = TILES_X * TILES_Y; + + private ExecutorService executor; + + @After + public void tearDown() { + if (executor != null) { + executor.shutdownNow(); + } + } + + private ShapeCollection buildScene(final ViewPanel panel, final int sphereCount) { + final ShapeCollection scene = panel.getRootShapeCollection(); + panel.getCamera().getTransform().setTranslation(new Point3D(0, 0, -600)); + + // Solid spheres spread across the whole viewport + for (int i = 0; i < sphereCount; i++) { + scene.addShape(new SolidPolygonSphere( + new Point3D((i % 6 - 3) * 90, (i / 6 - 2) * 80, 400), + 30, 10, Color.GREEN)); + } + + // Wireframe cubes (thin lines) + final LineAppearance appearance = new LineAppearance(2.0, Color.CYAN); + for (int i = 0; i < 30; i++) { + scene.addShape(new WireframeCube( + new Point3D((i % 10 - 5) * 70, (i / 10 - 1) * 90, 700), + 20, appearance)); + } + + // Thick lines crossing tile boundaries on both axes + scene.addShape(new Line( + new Point3D(-300, -200, 300), new Point3D(300, 250, 300), + Color.YELLOW, 8.0)); + scene.addShape(new Line( + new Point3D(-250, 250, 350), new Point3D(250, -250, 350), + Color.RED, 6.0)); + // Vertical thick line: X margin matters, Y range covers all rows + scene.addShape(new Line( + new Point3D(0, -300, 300), new Point3D(0, 300, 300), + Color.GREEN, 8.0)); + + // Glowing points (billboards): quad extends far beyond the vertex + for (int i = 0; i < 8; i++) { + scene.addShape(new GlowingPoint( + new Point3D((i % 4 - 2) * 120, (i / 4 - 0.5) * 150, 250), + 40, Color.WHITE)); + } + + // Text canvas: glyph extends beyond the character cell vertices + final TextCanvas text = new TextCanvas( + new Transform(new Point3D(-100, -50, 500)), + "Binning!", Color.WHITE, Color.BLUE); + scene.addShape(text); + + // A quad and a cube for variety + scene.addShape(SolidPolygon.quad( + new Point3D(-120, -120, 300), new Point3D(120, -120, 300), + new Point3D(120, 120, 300), new Point3D(-120, 120, 300), + Color.WHITE)); + scene.addShape(new SolidPolygonCube(new Point3D(200, 100, 350), 40, Color.RED)); + + // Large textured triangles spanning multiple tile rows. Big + // on screen — exercises textured-triangle binning, whose bounds + // formerly stayed (0,0) and binned it into the topmost segment only. + final Texture texture = TextureGenerator.solidWithBorder( + 32, Color.YELLOW, Color.WHITE, 2, 1); + scene.addShape(new TexturedTriangle( + new Vertex(new Point3D(-400, -300, 250), new Point2D(0, 0)), + new Vertex(new Point3D(400, -100, 250), new Point2D(1, 0)), + new Vertex(new Point3D(0, 400, 250), new Point2D(0.5, 1)), + texture)); + scene.addShape(new TexturedTriangle( + new Vertex(new Point3D(-500, 100, 300), new Point2D(0, 0)), + new Vertex(new Point3D(-100, -50, 300), new Point2D(1, 0)), + new Vertex(new Point3D(-300, 500, 300), new Point2D(0.5, 1)), + texture)); + + return scene; + } + + /** + * Paints the current frame's sorted queue into a fresh context using + * one full-range pass (no binning possible). + */ + private RenderingContext paintReference(final ShapeCollection scene) { + final RenderingContext reference = new RenderingContext(W, H, TILES_X, TILES_Y, 1); + scene.paintShapes(reference); + return reference; + } + + /** + * Paints the current frame's sorted queue tile-by-tile into a fresh + * context, using whatever bins are currently active. + */ + private RenderingContext paintTiled(final ShapeCollection scene) { + final RenderingContext tiled = new RenderingContext(W, H, TILES_X, TILES_Y, 1); + final int tileW = W / TILES_X; + final int tileH = H / TILES_Y; + for (int ty = 0; ty < TILES_Y; ty++) { + final int minY = ty * tileH; + final int maxY = (ty == TILES_Y - 1) ? H : (ty + 1) * tileH; + for (int tx = 0; tx < TILES_X; tx++) { + final int minX = tx * tileW; + final int maxX = (tx == TILES_X - 1) ? W : (tx + 1) * tileW; + final SegmentRenderingContext tileContext = new SegmentRenderingContext( + tiled, minY, maxY, ty * TILES_X + tx); + tileContext.renderMinX = minX; + tileContext.renderMaxX = maxX; + scene.paintShapes(tileContext); + } + } + return tiled; + } + + private void assertPixelsEqual(final RenderingContext expected, + final RenderingContext actual) { + final int tileW = W / TILES_X; + final int tileH = H / TILES_Y; + for (int i = 0; i < expected.pixels.length; i++) { + if (expected.pixels[i] != actual.pixels[i]) { + final int x = i % W; + final int y = i / W; + fail("pixel mismatch at (" + x + "," + y + ") tile (" + + Math.min(x / tileW, TILES_X - 1) + "," + + Math.min(y / tileH, TILES_Y - 1) + ")" + + ": expected=" + Integer.toHexString(expected.pixels[i]) + + " actual=" + Integer.toHexString(actual.pixels[i])); + } + } + } + + private void transformAndSort(final ViewPanel panel, final ShapeCollection scene, + final RenderingContext context) { + context.prepareForNewFrameRendering(); + scene.transformShapes(panel, context); + scene.sortShapes(); + } + + @Test + public void serialBinningMatchesFullQueuePaint() { + System.setProperty("java.awt.headless", "true"); + final ViewPanel panel = new ViewPanel(); + final ShapeCollection scene = buildScene(panel, 24); + + // Transform and sort once; paint is read-only on shape state, + // so the same frame can be painted into multiple buffers. + transformAndSort(panel, scene, new RenderingContext(W, H, TILES_X, TILES_Y, 1)); + + // The scene must contain textured triangles spanning multiple + // tiles, otherwise the binning-bounds regression (bounds left at + // 0,0 -> binned into the topmost segment only) is not covered + boolean anyTextured = false; + for (final AbstractCoordinateShape shape : scene.getQueuedShapes()) { + if (shape instanceof TexturedTriangle) { + anyTextured = true; + break; + } + } + assertTrue("scene must contain textured triangles", + anyTextured); + + final RenderingContext reference = paintReference(scene); + + // Serial binning (null executor) + scene.binShapesForTiles(TILES_X, TILES_Y, 0, W, H, null); + final int[] binSizes = scene.getBinSizes(); + assertNotNull("bins must be built", binSizes); + assertEquals(TILE_COUNT, binSizes.length); + + // Binning must reduce total per-tile iterations versus every + // tile iterating the whole queue + final int queueSize = scene.getQueuedShapeCount(); + int totalBinEntries = 0; + for (final int size : binSizes) { + totalBinEntries += size; + } + assertTrue("binning must reduce total paint iterations (" + + totalBinEntries + " >= " + (TILE_COUNT * queueSize) + ")", + totalBinEntries < TILE_COUNT * queueSize); + + assertPixelsEqual(reference, paintTiled(scene)); + } + + @Test + public void parallelBinningMatchesSerialAndFullQueuePaint() { + System.setProperty("java.awt.headless", "true"); + final ViewPanel panel = new ViewPanel(); + // Enough shapes to exceed the parallel binning threshold (8192) + final ShapeCollection scene = buildScene(panel, 80); + + transformAndSort(panel, scene, new RenderingContext(W, H, TILES_X, TILES_Y, 1)); + final int queueSize = scene.getQueuedShapeCount(); + assertTrue("scene must exceed the parallel binning threshold", + queueSize > 8192); + final RenderingContext reference = paintReference(scene); + + // Serial bins -> tiled paint A + scene.binShapesForTiles(TILES_X, TILES_Y, 0, W, H, null); + final int[] serialBinSizes = scene.getBinSizes(); + final RenderingContext serialTiled = paintTiled(scene); + + // Parallel bins -> tiled paint B + executor = Executors.newFixedThreadPool(8); + scene.binShapesForTiles(TILES_X, TILES_Y, 0, W, H, executor); + final int[] parallelBinSizes = scene.getBinSizes(); + final RenderingContext parallelTiled = paintTiled(scene); + + // Same bin layout, same pixels + org.junit.Assert.assertArrayEquals(serialBinSizes, parallelBinSizes); + assertPixelsEqual(reference, serialTiled); + assertPixelsEqual(reference, parallelTiled); + } + + @Test + public void fullRangeContextFallsBackToFullQueue() { + System.setProperty("java.awt.headless", "true"); + final ViewPanel panel = new ViewPanel(); + final ShapeCollection scene = buildScene(panel, 24); + + final RenderingContext context = new RenderingContext(W, H, TILES_X, TILES_Y, 1); + transformAndSort(panel, scene, context); + scene.binShapesForTiles(TILES_X, TILES_Y, 0, W, H, null); + + // A full-range context matches no single tile: must paint the full + // queue (verified by it producing non-background pixels at all) + scene.paintShapes(context); + boolean anyPainted = false; + for (final int pixel : context.pixels) { + if (pixel != 0) { + anyPainted = true; + break; + } + } + assertTrue("full-range paint must render shapes", anyPainted); + } +} diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangleBlendTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangleBlendTest.java new file mode 100644 index 0000000..881e716 --- /dev/null +++ b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangleBlendTest.java @@ -0,0 +1,239 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.math.Vertex; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.TextureBitmap; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.util.Random; + +import static org.junit.Assert.assertEquals; + +/** + * Pixel-exactness proof for the optimized textured scanline renderer: + * the one-multiply alpha blend and the clamp-free fast path must produce + * output identical to the legacy implementation, bit for bit. + */ +public class TexturedTriangleBlendTest { + + /** Legacy two-multiply blend, the original semantics. */ + private static int legacyBlendChannel(final int src, final int dest, final int alpha) { + return ((dest * (255 - alpha)) + (src * alpha)) >> 8; + } + + /** Optimized one-multiply blend, must equal legacy for every input. */ + private static int fastBlendChannel(final int src, final int dest, final int alpha) { + return dest + ((alpha * (src - dest) - dest) >> 8); + } + + @Test + public void oneMultiplyBlendMatchesLegacyBlend() { + final int[] channelValues = {0, 1, 2, 63, 127, 128, 200, 254, 255}; + for (int alpha = 0; alpha <= 255; alpha++) { + for (final int src : channelValues) { + for (final int dest : channelValues) { + assertEquals("src=" + src + " dest=" + dest + " alpha=" + alpha, + legacyBlendChannel(src, dest, alpha), + fastBlendChannel(src, dest, alpha)); + } + } + } + final Random random = new Random(42); + for (int i = 0; i < 1_000_000; i++) { + final int src = random.nextInt(256); + final int dest = random.nextInt(256); + final int alpha = random.nextInt(256); + assertEquals("src=" + src + " dest=" + dest + " alpha=" + alpha, + legacyBlendChannel(src, dest, alpha), + fastBlendChannel(src, dest, alpha)); + } + } + + /** + * Legacy scanline implementation (pre-optimization), used as the + * oracle: the optimized drawHorizontalLineZ must match it exactly. + */ + private static void legacyDrawHorizontalLine( + final PolygonBorderInterpolator line1, final PolygonBorderInterpolator line2, + final int y, final int[] renderBufferPixels, final int width, + final int renderMinX, final int renderMaxX, + final TextureBitmap textureBitmap) { + line1.setCurrentY(y); + line2.setCurrentY(y); + + int x1 = line1.getX(); + int x2 = line2.getX(); + + final double tx2, ty2; + final double tx1, ty1; + + if (x1 <= x2) { + tx1 = line1.getTX() * textureBitmap.multiplicationFactor; + ty1 = line1.getTY() * textureBitmap.multiplicationFactor; + tx2 = line2.getTX() * textureBitmap.multiplicationFactor; + ty2 = line2.getTY() * textureBitmap.multiplicationFactor; + } else { + final int tmp = x1; + x1 = x2; + x2 = tmp; + tx1 = line2.getTX() * textureBitmap.multiplicationFactor; + ty1 = line2.getTY() * textureBitmap.multiplicationFactor; + tx2 = line1.getTX() * textureBitmap.multiplicationFactor; + ty2 = line1.getTY() * textureBitmap.multiplicationFactor; + } + + final double realWidth = x2 - x1; + final double realX1 = x1; + + if (x1 < renderMinX) + x1 = renderMinX; + if (x2 >= renderMaxX) + x2 = renderMaxX; + + int renderBufferOffset = (y * width) + x1; + + final double twidth = tx2 - tx1; + final double theight = ty2 - ty1; + + final double txStep = twidth / realWidth; + final double tyStep = theight / realWidth; + + double tx = tx1 + txStep * (x1 - realX1); + double ty = ty1 + tyStep * (x1 - realX1); + + final int[] texPixels = textureBitmap.pixels; + final int texW = textureBitmap.width; + final int texH = textureBitmap.height; + final int texWMinus1 = texW - 1; + final int texHMinus1 = texH - 1; + + for (int x = x1; x < x2; x++) { + int itx = (int) tx; + int ity = (int) ty; + + if (itx < 0) itx = 0; + else if (itx > texWMinus1) itx = texWMinus1; + + if (ity < 0) ity = 0; + else if (ity > texHMinus1) ity = texHMinus1; + + final int srcPixel = texPixels[ity * texW + itx]; + final int srcAlpha = (srcPixel >> 24) & 0xff; + + if (srcAlpha != 0) { + if (srcAlpha == 255) { + renderBufferPixels[renderBufferOffset] = srcPixel; + } else { + final int destPixel = renderBufferPixels[renderBufferOffset]; + final int destR = (destPixel >> 16) & 0xff; + final int destG = (destPixel >> 8) & 0xff; + final int destB = destPixel & 0xff; + + final int r = legacyBlendChannel((srcPixel >> 16) & 0xff, destR, srcAlpha); + final int g = legacyBlendChannel((srcPixel >> 8) & 0xff, destG, srcAlpha); + final int b = legacyBlendChannel(srcPixel & 0xff, destB, srcAlpha); + + renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b; + } + } + + tx += txStep; + ty += tyStep; + renderBufferOffset++; + } + } + + @Test + public void scanlineMatchesLegacyImplementation() throws Exception { + final int width = 96; + final int height = 8; + final Random random = new Random(1337); + + // Texture with a mix of transparent, semi-transparent and opaque pixels + final int texW = 16, texH = 16; + final int[] texPixels = new int[texW * texH]; + for (int i = 0; i < texPixels.length; i++) { + final int alpha; + switch (random.nextInt(4)) { + case 0: alpha = 0; break; + case 1: alpha = 255; break; + default: alpha = 1 + random.nextInt(254); + } + texPixels[i] = (alpha << 24) | (random.nextInt(256) << 16) + | (random.nextInt(256) << 8) | random.nextInt(256); + } + final TextureBitmap textureBitmap = new TextureBitmap(texW, texH, texPixels, 1.0); + + final TexturedTriangle triangle = new TexturedTriangle( + new Vertex(new Point3D(0, 0, 0), new Point2D(0, 0)), + new Vertex(new Point3D(1, 0, 0), new Point2D(1, 0)), + new Vertex(new Point3D(0, 1, 0), new Point2D(0, 1)), null); + + final Method draw = TexturedTriangle.class.getDeclaredMethod("drawHorizontalLineZ", + PolygonBorderInterpolator.class, PolygonBorderInterpolator.class, + int.class, RenderingContext.class, TextureBitmap.class); + draw.setAccessible(true); + + for (int iteration = 0; iteration < 5000; iteration++) { + // Random span endpoints, including out-of-texture and + // out-of-render-bounds cases, and reversed X order + final double sx1 = random.nextDouble() * width * 1.5 - width * 0.25; + final double sx2 = random.nextDouble() * width * 1.5 - width * 0.25; + final double u1 = random.nextDouble() * 2.0 - 0.5; + final double v1 = random.nextDouble() * 2.0 - 0.5; + final double u2 = random.nextDouble() * 2.0 - 0.5; + final double v2 = random.nextDouble() * 2.0 - 0.5; + final int y = 1 + random.nextInt(height - 2); + + final PolygonBorderInterpolator line1 = new PolygonBorderInterpolator(); + final PolygonBorderInterpolator line2 = new PolygonBorderInterpolator(); + line1.setPoints(new Point2D(sx1, y), new Point2D(sx1, y + 1), + new Point2D(u1, v1), new Point2D(u1, v1)); + line2.setPoints(new Point2D(sx2, y), new Point2D(sx2, y + 1), + new Point2D(u2, v2), new Point2D(u2, v2)); + + final int[] actual = new int[width * height]; + final int[] expected = new int[width * height]; + for (int i = 0; i < actual.length; i++) { + actual[i] = expected[i] = 0xFF000000 | random.nextInt(0xFFFFFF); + } + + final RenderingContext context = new RenderingContext(width, height, 1); + System.arraycopy(actual, 0, context.pixels, 0, actual.length); + context.renderMinX = 0; + context.renderMaxX = width; + + // Fresh interpolators for the oracle (setCurrentY mutates them) + final PolygonBorderInterpolator oLine1 = new PolygonBorderInterpolator(); + final PolygonBorderInterpolator oLine2 = new PolygonBorderInterpolator(); + oLine1.setPoints(new Point2D(sx1, y), new Point2D(sx1, y + 1), + new Point2D(u1, v1), new Point2D(u1, v1)); + oLine2.setPoints(new Point2D(sx2, y), new Point2D(sx2, y + 1), + new Point2D(u2, v2), new Point2D(u2, v2)); + + java.util.Arrays.fill(context.depth, Float.NEGATIVE_INFINITY); + draw.invoke(triangle, line1, line2, y, context, textureBitmap); + legacyDrawHorizontalLine(oLine1, oLine2, y, expected, width, + 0, width, textureBitmap); + + for (int i = 0; i < expected.length; i++) { + if (expected[i] != context.pixels[i]) { + final int px = i % width, py = i / width; + throw new AssertionError("iteration " + iteration + + " pixel(" + px + "," + py + "): expected " + + Integer.toHexString(expected[i]) + " but got " + + Integer.toHexString(context.pixels[i]) + + " [span " + sx1 + ".." + sx2 + " uv (" + + u1 + "," + v1 + ")->(" + u2 + "," + v2 + ")]"); + } + } + } + } +} diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTrianglePerspectiveTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTrianglePerspectiveTest.java new file mode 100644 index 0000000..0c04e7f --- /dev/null +++ b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTrianglePerspectiveTest.java @@ -0,0 +1,305 @@ +/* + * Aukio 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon; + +import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.RenderingContext; +import eu.svjatoslav.aukio.e3d.math.Vertex; +import eu.svjatoslav.aukio.e3d.renderer.raster.texture.TextureBitmap; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.util.Random; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Correctness proof for the Quake-style subdivided perspective scanline + * renderer: compared against an exact per-pixel divide oracle that mirrors + * the production span walk, texel selection must never deviate by more + * than one texel per axis (the ulp-boundary artifact inherent to + * truncation, present in the affine path as well). + */ +public class TexturedTrianglePerspectiveTest { + + private static final int TEX_W = 64; + private static final int TEX_H = 64; + + private static TextureBitmap numberedTexture() { + final int[] texPixels = new int[TEX_W * TEX_H]; + for (int i = 0; i < texPixels.length; i++) + texPixels[i] = 0xFF000000 | i; + return new TextureBitmap(TEX_W, TEX_H, texPixels, 1.0); + } + + /** + * Exact oracle: replicates the production span setup (rounded + * endpoints, gradients over the unclipped width, clip compensation), + * but recovers u/v with a division at EVERY pixel. + */ + private static void exactDrawHorizontalLine( + final double su1, final double sv1, final double sw1, + final double su2, final double sv2, final double sw2, + final int rx1, final int rx2, + final int clipMinX, final int clipMaxX, + final int y, final int[] renderBufferPixels, final int width, + final TextureBitmap textureBitmap) { + + final double realWidth = rx2 - rx1; + int x1 = Math.max(rx1, clipMinX); + int x2 = Math.min(rx2, clipMaxX); + if (x2 - x1 <= 0) + return; + + final double dsu = (su2 - su1) / realWidth; + final double dsv = (sv2 - sv1) / realWidth; + final double dsw = (sw2 - sw1) / realWidth; + + double su = su1 + dsu * (x1 - rx1); + double sv = sv1 + dsv * (x1 - rx1); + double sw = sw1 + dsw * (x1 - rx1); + + int renderBufferOffset = (y * width) + x1; + + final int[] texPixels = textureBitmap.pixels; + + for (int x = x1; x < x2; x++) { + final double invW = 1d / sw; + int itx = (int) (su * invW); + int ity = (int) (sv * invW); + + if (itx < 0) itx = 0; + else if (itx > TEX_W - 1) itx = TEX_W - 1; + if (ity < 0) ity = 0; + else if (ity > TEX_H - 1) ity = TEX_H - 1; + + renderBufferPixels[renderBufferOffset] = texPixels[ity * TEX_W + itx]; + + su += dsu; + sv += dsv; + sw += dsw; + renderBufferOffset++; + } + } + + @Test + public void subdividedPerspectiveStaysWithinOneTexelOfExact() throws Exception { + final int width = 256; + final int height = 8; + final Random random = new Random(2026); + final TextureBitmap textureBitmap = numberedTexture(); + + final TexturedTriangle triangle = new TexturedTriangle( + new Vertex(new Point3D(0, 0, 0), new Point2D(0, 0)), + new Vertex(new Point3D(1, 0, 0), new Point2D(1, 0)), + new Vertex(new Point3D(0, 1, 0), new Point2D(0, 1)), null); + + final Method draw = TexturedTriangle.class.getDeclaredMethod( + "drawHorizontalLinePerspectiveZ", + PerspectiveBorderInterpolator.class, PerspectiveBorderInterpolator.class, + int.class, RenderingContext.class, TextureBitmap.class); + draw.setAccessible(true); + + long totalPixels = 0; + long identicalPixels = 0; + int maxDeviation = 0; + + for (int iteration = 0; iteration < 3000; iteration++) { + // Random steep-perspective span: z varies up to 60x across the + // span, texture coords may overshoot the texture (clamp path), + // and the span may extend past the render bounds (clip path) + final double sx1 = random.nextDouble() * width * 0.5; + final double sx2 = sx1 + 4 + random.nextDouble() * (width - 8); + final double z1 = 0.5 + random.nextDouble() * 31.5; + final double z2 = 0.5 + random.nextDouble() * 31.5; + final double u1 = random.nextDouble() * 144 - 16; + final double v1 = random.nextDouble() * 144 - 16; + final double u2 = random.nextDouble() * 144 - 16; + final double v2 = random.nextDouble() * 144 - 16; + final int y = 1 + random.nextInt(height - 2); + + final double sw1 = 1d / z1, sw2 = 1d / z2; + final double su1 = u1 * sw1, sv1 = v1 * sw1; + final double su2 = u2 * sw2, sv2 = v2 * sw2; + + final PerspectiveBorderInterpolator line1 = new PerspectiveBorderInterpolator(); + final PerspectiveBorderInterpolator line2 = new PerspectiveBorderInterpolator(); + line1.setPoints(new Point2D(sx1, y), new Point2D(sx1, y + 1), su1, sv1, sw1, su1, sv1, sw1); + line2.setPoints(new Point2D(sx2, y), new Point2D(sx2, y + 1), su2, sv2, sw2, su2, sv2, sw2); + + final RenderingContext context = new RenderingContext(width, height, 1); + context.renderMinX = 0; + context.renderMaxX = width; + + java.util.Arrays.fill(context.depth, Float.NEGATIVE_INFINITY); + draw.invoke(triangle, line1, line2, y, context, textureBitmap); + + final int rx1 = (int) Math.round(sx1); + final int rx2 = (int) Math.round(sx2); + final int[] expected = new int[width * height]; + exactDrawHorizontalLine(su1, sv1, sw1, su2, sv2, sw2, + rx1, rx2, 0, width, y, expected, width, textureBitmap); + + final int cx1 = Math.max(rx1, 0); + final int cx2 = Math.min(rx2, width); + for (int x = cx1; x < cx2; x++) { + final int a = context.pixels[y * width + x] & 0xFFFFFF; + final int e = expected[y * width + x] & 0xFFFFFF; + totalPixels++; + if (a == e) { + identicalPixels++; + } else { + final int deviation = Math.max( + Math.abs((a % TEX_W) - (e % TEX_W)), + Math.abs((a / TEX_W) - (e / TEX_W))); + maxDeviation = Math.max(maxDeviation, deviation); + } + } + } + + final double identicalRatio = (double) identicalPixels / totalPixels; + if (maxDeviation > 1) { + fail("texel deviation " + maxDeviation + " exceeds 1 (identical=" + + (identicalRatio * 100) + "% over " + totalPixels + " pixels)"); + } + assertTrue("suspiciously few pixels tested: " + totalPixels, totalPixels > 100000); + System.out.println("perspective-16 vs exact: identical=" + (identicalRatio * 100) + + "% maxTexelDeviation=" + maxDeviation + + " over " + totalPixels + " pixels"); + } + + @Test + public void affineWithinHalfTexelBound() throws Exception { + // The paint() shortcut uses affine mapping when + // texelSpan * (zRatio-1) < 2. Verify: for random spans satisfying + // that bound, the affine renderer stays within one texel of the + // exact per-pixel divide oracle. + final int width = 320; + final int height = 8; + final Random random = new Random(77); + final TextureBitmap textureBitmap = numberedTexture(); + + final TexturedTriangle triangle = new TexturedTriangle( + new Vertex(new Point3D(0, 0, 0), new Point2D(0, 0)), + new Vertex(new Point3D(1, 0, 0), new Point2D(1, 0)), + new Vertex(new Point3D(0, 1, 0), new Point2D(0, 1)), null); + + final Method drawAffine = TexturedTriangle.class.getDeclaredMethod( + "drawHorizontalLineZ", + PolygonBorderInterpolator.class, PolygonBorderInterpolator.class, + int.class, RenderingContext.class, TextureBitmap.class); + drawAffine.setAccessible(true); + + long totalPixels = 0; + int maxDeviation = 0; + + for (int iteration = 0; iteration < 3000; iteration++) { + final double sx1 = random.nextDouble() * width * 0.5; + final double spanD = 4 + random.nextDouble() * 296; + final double sx2 = Math.min(sx1 + spanD, width * 1.2); + final double u1 = random.nextDouble() * 56; + final double v1 = random.nextDouble() * 56; + final double u2 = random.nextDouble() * 56; + final double v2 = random.nextDouble() * 56; + // z ratio strictly inside the bound, driven by the TEXEL span + final double texelSpan = Math.max(Math.abs(u2 - u1), Math.abs(v2 - v1)); + final double z1 = 1 + random.nextDouble() * 30; + final double r = 1 + random.nextDouble() * (1.9 / Math.max(texelSpan, 0.5)); + final double z2 = z1 * r; + final int y = 1 + random.nextInt(height - 2); + + final PolygonBorderInterpolator line1 = new PolygonBorderInterpolator(); + final PolygonBorderInterpolator line2 = new PolygonBorderInterpolator(); + line1.setPoints(new Point2D(sx1, y), new Point2D(sx1, y + 1), + new Point2D(u1, v1), new Point2D(u1, v1)); + line2.setPoints(new Point2D(sx2, y), new Point2D(sx2, y + 1), + new Point2D(u2, v2), new Point2D(u2, v2)); + + final RenderingContext context = new RenderingContext(width, height, 1); + context.renderMinX = 0; + context.renderMaxX = width; + + java.util.Arrays.fill(context.depth, Float.NEGATIVE_INFINITY); + drawAffine.invoke(triangle, line1, line2, y, context, textureBitmap); + + // Exact oracle over the same span (gradients from z1/z2) + final double sw1 = 1d / z1, sw2 = 1d / z2; + final int rx1 = (int) Math.round(sx1); + final int rx2 = (int) Math.round(sx2); + final int[] expected = new int[width * height]; + exactDrawHorizontalLine(u1 * sw1, v1 * sw1, sw1, u2 * sw2, v2 * sw2, sw2, + rx1, rx2, 0, width, y, expected, width, textureBitmap); + + final int cx1 = Math.max(rx1, 0); + final int cx2 = Math.min(rx2, width); + for (int x = cx1; x < cx2; x++) { + final int a = context.pixels[y * width + x] & 0xFFFFFF; + final int e = expected[y * width + x] & 0xFFFFFF; + totalPixels++; + final int deviation = Math.max( + Math.abs((a % TEX_W) - (e % TEX_W)), + Math.abs((a / TEX_W) - (e / TEX_W))); + maxDeviation = Math.max(maxDeviation, deviation); + } + } + + if (maxDeviation > 1) { + fail("affine deviation " + maxDeviation + " exceeds 1 texel within the bound" + + " over " + totalPixels + " pixels"); + } + assertTrue("suspiciously few pixels tested: " + totalPixels, totalPixels > 100000); + System.out.println("affine within bound: maxTexelDeviation=" + maxDeviation + + " over " + totalPixels + " pixels"); + } + + @Test + public void faceOnSpanMatchesAffineWithinOneTexel() throws Exception { + // Constant z across the span: perspective correction must reduce + // to the affine mapping (u linear in x), modulo the ulp-boundary + // truncation artifact. + final int width = 200; + final int height = 4; + final TextureBitmap textureBitmap = numberedTexture(); + + final TexturedTriangle triangle = new TexturedTriangle( + new Vertex(new Point3D(0, 0, 0), new Point2D(0, 0)), + new Vertex(new Point3D(1, 0, 0), new Point2D(1, 0)), + new Vertex(new Point3D(0, 1, 0), new Point2D(0, 1)), null); + + final Method draw = TexturedTriangle.class.getDeclaredMethod( + "drawHorizontalLinePerspectiveZ", + PerspectiveBorderInterpolator.class, PerspectiveBorderInterpolator.class, + int.class, RenderingContext.class, TextureBitmap.class); + draw.setAccessible(true); + + final double z = 5.0; + final double sw = 1d / z; + final int y = 1; + final double u1 = 2.0, v1 = 3.0, u2 = 30.0, v2 = 10.0; + + final PerspectiveBorderInterpolator line1 = new PerspectiveBorderInterpolator(); + final PerspectiveBorderInterpolator line2 = new PerspectiveBorderInterpolator(); + line1.setPoints(new Point2D(10, y), new Point2D(10, y + 1), u1 * sw, v1 * sw, sw, u1 * sw, v1 * sw, sw); + line2.setPoints(new Point2D(190, y), new Point2D(190, y + 1), u2 * sw, v2 * sw, sw, u2 * sw, v2 * sw, sw); + + final RenderingContext context = new RenderingContext(width, height, 1); + context.renderMinX = 0; + context.renderMaxX = width; + java.util.Arrays.fill(context.depth, Float.NEGATIVE_INFINITY); + draw.invoke(triangle, line1, line2, y, context, textureBitmap); + + for (int x = 10; x < 190; x++) { + final double exactU = u1 + (u2 - u1) * (x - 10) / 180.0; + final double exactV = v1 + (v2 - v1) * (x - 10) / 180.0; + final int actualTexel = context.pixels[y * width + x] & 0xFFFFFF; + final int du = Math.abs((actualTexel % TEX_W) - ((int) exactU)); + final int dv = Math.abs((actualTexel / TEX_W) - ((int) exactV)); + assertTrue("pixel " + x + ": texel deviation u=" + du + " v=" + dv, + du <= 1 && dv <= 1); + } + } +}