From a4dd962eb49179a0b31548f876091ac1e580813e Mon Sep 17 00:00:00 2001 From: Svjatoslav Agejenko Date: Mon, 6 Apr 2026 16:52:00 +0300 Subject: [PATCH] docs(rendering): document render pipeline, lighting, and optimize normals MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Add comprehensive documentation for the rendering pipeline with SVG diagrams showing the Shapes→Transform→Sort→Paint→Blit flow. Document double-buffering, painter's algorithm, and frame listeners. Add shading & lighting guide covering Lambert cosine law, light sources, ambient light, and distance attenuation with visual diagrams. Refactor Plane to expose computeNormal() for zero-allocation normal computation, and move canvas clearing into parallel paint phase for better memory bandwidth utilization. --- TODO.org | 1 - doc/index.org | 77 +++- doc/rendering-loop/index.org | 335 +++++++++++--- doc/shading/Shaded sphere.png | Bin 0 -> 23417 bytes doc/shading/index.org | 415 ++++++++++++++++++ .../svjatoslav/sixth/e3d/geometry/Plane.java | 61 ++- .../svjatoslav/sixth/e3d/gui/ViewPanel.java | 29 +- .../basic/solidpolygon/SolidPolygon.java | 62 +-- 8 files changed, 832 insertions(+), 148 deletions(-) create mode 100644 doc/shading/Shaded sphere.png create mode 100644 doc/shading/index.org diff --git a/TODO.org b/TODO.org index 51448e8..ca72dc7 100644 --- a/TODO.org +++ b/TODO.org @@ -72,7 +72,6 @@ the sweet spot. Now system will need to compute each unique point in 3D only once. Polygons can share coordinates. - * Features :PROPERTIES: :CUSTOM_ID: features diff --git a/doc/index.org b/doc/index.org index 1fbf08f..7d8863f 100644 --- a/doc/index.org +++ b/doc/index.org @@ -152,7 +152,52 @@ Also add the repository (the library is not on Maven Central): ** Main render loop -- To understand main render loop, see dedicated page: [[file:rendering-loop/][Rendering loop]] +#+BEGIN_EXPORT html + + + + + + + + + + + Shapes + + + Transform + + + Sort + + + Paint + + + Blit + + + Screen + + + + + + + + + + 3D vertices + world→screen + back-to-front + 8 threads + copy buffer + +#+END_EXPORT + +To understand main render loop, see dedicated page: [[file:rendering-loop/][Rendering loop]] ** Coordinate System (X, Y, Z) :PROPERTIES: @@ -328,11 +373,31 @@ 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. -- *Face normal*: one normal per triangle -- *Vertex normal*: one normal per vertex (averaged from adjacent faces for smooth shading) -- =dot(L, N)= → surface brightness -- Flat shading → face normals -- Gouraud/Phong → vertex normals + interpolation +**Use cases:** + +| Use case | API | Computation | Location | +|----------------------+----------------------------------------------+----------------------------+-------------------| +| BSP/CSG operations | [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html#getPlane()][SolidPolygon.getPlane()]] → [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/geometry/Plane.html#normal][Plane.normal]] | Lazy-cached once | =Plane= | +| Per-frame shading | [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/geometry/Plane.html#computeNormal()][Plane.computeNormal()]] → =cachedNormal= field | Recomputed every frame | =SolidPolygon= | +| Lighting calculation | [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightingManager.html#computeLighting()][LightingManager.computeLighting()]] | Uses normal via =dot(L,N)= | =LightingManager= | + +**Implementation notes:** +- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/geometry/Plane.html#computeNormal()][Plane.computeNormal()]]: shared zero-allocation helper for computing normals from three points +- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/geometry/Plane.html][Plane]]: stores normals in Hesse normal form (normal + distance) for BSP spatial partitioning +- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/math/Vertex.html#normal][Vertex.normal]]: optional field for CSG polygon splitting (not used for rendering) + +** Shading & Lighting + +#+attr_html: :width 600px +#+attr_latex: :width 600px +[[file:shading/Shaded%20sphere.png]] + + +*Sixth 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]]. ** Mesh :PROPERTIES: diff --git a/doc/rendering-loop/index.org b/doc/rendering-loop/index.org index 1f09cac..2a06c19 100644 --- a/doc/rendering-loop/index.org +++ b/doc/rendering-loop/index.org @@ -37,6 +37,77 @@ 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: + +#+BEGIN_EXPORT html + + + + + + + + + + + Shapes + + + Transform + + + Sort + + + Paint + + + Blit + + + Screen + + + + + + + + + + 3D vertices + world→screen + back-to-front + 8 threads + copy buffer + +#+END_EXPORT + +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]]) | +| Sort | Unordered shapes | Ordered by depth | Ensure correct visibility (far objects painted first) | +| Paint | Sorted shapes | Pixels in buffer | Clear segments (parallel) and rasterize triangles into pixel array | +| Blit | Pixel buffer | Screen image | Copy completed frame to display | + +This pipeline runs repeatedly, typically 60 times per second (60 FPS). +Even if nothing moves, the loop continues running—but the engine [[#frame-listeners][skips +unnecessary work]] when the scene is static. + ** Main loop structure :PROPERTIES: :CUSTOM_ID: main-loop-structure @@ -61,57 +132,34 @@ You can stop it explicitly with [[https://www3.svjatoslav.eu/projects/sixth-3d/a The engine supports two modes: -- *Target FPS mode*: Set with [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/ViewPanel.html#setFrameRate(int)][setFrameRate(int)]]. The thread sleeps - between frames to maintain the target rate. If rendering takes - longer than the frame interval, the engine catches up naturally - without sleeping. +- *Target FPS mode*: Set with [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/ViewPanel.html#setFrameRate(int)][setFrameRate(int)]]. + The engine tries to maintain the target rate by sleeping between frames. -- *Unlimited mode*: Set =setFrameRate(0)= or negative. No sleeping — - renders as fast as possible. Useful for benchmarking. + - *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. -** Frame listeners -:PROPERTIES: -:CUSTOM_ID: frame-listeners -:END: + - *When rendering is faster than target*: The thread sleeps to limit FPS + to the target rate, avoiding unnecessary CPU usage. -Before each frame, the engine notifies all registered [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/FrameListener.html][FrameListener]]s: - -#+BEGIN_SRC java -viewPanel.addFrameListener((panel, deltaMs) -> { - // Update animations, physics, game logic - shape.rotate(0.01); - return true; // true = force repaint -}); -#+END_SRC + 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) -Frame listeners can trigger repaints by returning =true=. Built-in listeners include: -- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/Camera.html][Camera]] — handles keyboard/mouse navigation -- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/humaninput/InputManager.html][InputManager]] — processes input events +- *Unlimited mode*: Set =setFrameRate(0)= or negative. No sleeping — + renders as fast as possible. Useful for benchmarking. * Rendering phases :PROPERTIES: :CUSTOM_ID: rendering-phases :END: -Each frame goes through 6 phases. Open the Developer Tools panel (F12) -to see these phases logged in real-time: +Each frame goes through 5 phases: -** Phase 1: Clear canvas +** Phase 1: Transform shapes :PROPERTIES: -:CUSTOM_ID: phase-1-clear-canvas -:END: - -The pixel buffer is filled with the background color (default: black). - -#+BEGIN_SRC java -Arrays.fill(pixels, 0, width * height, backgroundColorRgb); -#+END_SRC - -This is a simple =Arrays.fill= operation — very fast, single-threaded. - -** Phase 2: Transform shapes -:PROPERTIES: -:CUSTOM_ID: phase-2-transform-shapes +:CUSTOM_ID: phase-1-transform-shapes :END: All shapes are transformed from world space to screen space: @@ -123,11 +171,24 @@ All shapes are transformed from world space to screen space: - Calculate =onScreenZ= for depth 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. + This is single-threaded but very fast — just math, no pixel operations. -** Phase 3: Sort shapes +** Phase 2: Sort shapes :PROPERTIES: -:CUSTOM_ID: phase-3-sort-shapes +:CUSTOM_ID: phase-2-sort-shapes :END: Shapes are sorted by =onScreenZ= (depth) in descending order: @@ -136,15 +197,47 @@ Shapes are sorted by =onScreenZ= (depth) in descending order: Collections.sort(queuedShapes, (a, b) -> Double.compare(b.onScreenZ, a.onScreenZ)); #+END_SRC -Back-to-front sorting is essential for correct transparency and -occlusion. Shapes further from the camera are painted first. +*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. + +#+BEGIN_EXPORT html + + + + + + Far (Z=500) — painted first + + + Medium (Z=300) — painted second + + + Near (Z=100) — painted last + +#+END_EXPORT + +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. -** Phase 4: Paint shapes (multi-threaded) +The =onScreenZ= value represents distance from the camera after +transformation. Larger values = further away. + +** Phase 3: Clear and paint segments (multi-threaded) :PROPERTIES: -:CUSTOM_ID: phase-4-paint-shapes +:CUSTOM_ID: phase-3-clear-paint-segments :END: -The screen is divided into 8 horizontal segments, each rendered by a separate thread: +The screen is divided into 8 horizontal segments, each processed by a separate thread. Each thread performs two operations: + +1. *Clear segment*: Fill its Y-range with background color +2. *Paint shapes*: Render all shapes within that Y-range + +Both operations happen within the same thread task, ensuring clearing completes before painting begins. This provides greater RAM bandwidth utilization compared to single-threaded clearing. #+BEGIN_EXPORT html @@ -170,6 +263,7 @@ The screen is divided into 8 horizontal segments, each rendered by a separate th Each thread: - Gets a =SegmentRenderingContext= with Y-bounds (minY, maxY) +- Clears its segment to background color (parallel memory fill) - Iterates all shapes and paints pixels within its Y-range - Clips triangles/lines at segment boundaries - Detects mouse hits (before clipping) @@ -180,14 +274,18 @@ A =CountDownLatch= waits for all 8 threads to complete before proceeding. The fixed thread pool (=Executors.newFixedThreadPool(8)=) avoids the overhead of creating threads per frame. -** Phase 5: Combine mouse results +**Why parallel clearing?** Each thread clears its own memory region +(disjoint Y-ranges), avoiding synchronization overhead while maximizing +memory bandwidth utilization on multi-core systems. + +** Phase 4: Combine mouse results :PROPERTIES: -:CUSTOM_ID: phase-5-combine-mouse-results +:CUSTOM_ID: phase-4-combine-mouse-results :END: During painting, each segment tracks which shape is under the mouse cursor. Since all segments paint the same shapes (just different Y-ranges), they -should all report the same hit. Phase 5 takes the first non-null result: +should all report the same hit. Phase 4 takes the first non-null result: #+BEGIN_SRC java for (SegmentRenderingContext ctx : segmentContexts) { @@ -198,13 +296,13 @@ for (SegmentRenderingContext ctx : segmentContexts) { } #+END_SRC -** Phase 6: Blit to screen +** Phase 5: Blit to screen :PROPERTIES: -:CUSTOM_ID: phase-6-blit-to-screen +:CUSTOM_ID: phase-5-blit-to-screen :END: The rendered =BufferedImage= is copied to the screen using -[[https://docs.oracle.com/javase/21/docs/api/java/awt/image/BufferStrategy.html][BufferStrategy]] for tear-free page-flipping: +[[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 { @@ -217,25 +315,138 @@ bufferStrategy.show(); Toolkit.getDefaultToolkit().sync(); #+END_SRC +*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. + +#+BEGIN_EXPORT html + + + + + 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 + +#+END_EXPORT + +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. -* Smart repaint skipping + +* Frame listeners and smart repaint skipping :PROPERTIES: -:CUSTOM_ID: smart-repaint-skipping +:CUSTOM_ID: frame-listeners +:ID: e360a877-cca6-4cba-a9a4-ea40b0f1a183 :END: -The engine avoids unnecessary rendering: +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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/ViewPanel.html#addFrameListener(eu.svjatoslav.sixth.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 velocity is zero* (built-in Camera listener returns false when not moving) +- *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. + +#+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 -- =viewRepaintNeeded= flag: Set to =true= only when something changes -- Frame listeners can return =false= to skip repaint -- Resizing, component events, and explicit =repaintDuringNextViewUpdate()= - calls set the flag +The engine registers these listeners by default: +- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/Camera.html][Camera]] — returns true when user is actively navigating (velocity > 0) +- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/humaninput/InputManager.html][InputManager]] — processes mouse/keyboard events -This means a static scene consumes almost zero CPU — the render thread -just spins checking the flag. +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: @@ -249,7 +460,7 @@ The [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e | =pixels[]= | Raw pixel buffer (int[] in RGB format) | | =bufferedImage= | Java2D wrapper around pixels | | =graphics= | Graphics2D for text, lines, shapes | -| =width=, =height= | Screen dimensions | +| =width=, =height= | Rendering area dimensions | | =centerCoordinate= | Screen center (for projection) | | =projectionScale= | Perspective scale factor | | =frameNumber= | Monotonically increasing frame counter | diff --git a/doc/shading/Shaded sphere.png b/doc/shading/Shaded sphere.png new file mode 100644 index 0000000000000000000000000000000000000000..fbc6487394d71c8c4916c026c3137e28643ca1f2 GIT binary patch literal 23417 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 literal 0 HcmV?d00001 diff --git a/doc/shading/index.org b/doc/shading/index.org new file mode 100644 index 0000000..62f6d62 --- /dev/null +++ b/doc/shading/index.org @@ -0,0 +1,415 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Shading & Lighting - Sixth 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 + +#+begin_export html + +#+end_export + +[[file:../index.org][Back to main documentation]] + +* Shading & Lighting +:PROPERTIES: +:CUSTOM_ID: shading-lighting +:END: + +#+attr_html: :class responsive-img +#+attr_latex: :width 1000px +[[file:Shaded sphere.png]] + +*Sixth 3D* implements *flat shading* using the 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: + +#+BEGIN_EXPORT html + + + + + + + + + + + + + + + N̂ + normal + + + + Light + + + + L̂ + + + + θ + + + + brightness = + dot(N̂, L̂) + = cos(θ) + + + + + brightness + ← angle determines intensity + +#+END_EXPORT + +The *Lambert cosine law* states that the brightness of a surface depends +on the angle between its normal vector and the light direction: + +- Surface facing the light (θ = 0°): maximum brightness (=cos(0°) = 1.0=) +- Surface at 45° angle: moderate brightness (=cos(45°) = 0.71=) +- Surface perpendicular to light (θ = 90°): no direct light (=cos(90°) = 0.0=) + +This is computed as the *dot product* of the unit normal vector (N̂) and +unit light direction vector (L̂). The dot product automatically gives the +cosine of the angle between them. + +** 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.sixth.e3d.renderer.raster.lighting.LightSource; +import eu.svjatoslav.sixth.e3d.geometry.Point3D; +import eu.svjatoslav.sixth.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. + +** Ambient Light +:PROPERTIES: +:CUSTOM_ID: ambient-light +:END: + +#+BEGIN_EXPORT html + + + + + + no ambient + (pure black) + + + → + + + + ambient + + + + ambient provides base illumination + +#+END_EXPORT + +*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) +- 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 + +** Distance Attenuation +:PROPERTIES: +:CUSTOM_ID: distance-attenuation +:END: + +#+BEGIN_EXPORT html + + + + + + Light + + + + d = 100 + d = 250 + + + + bright + + + + medium + + + + dim + + + + attenuation = + 1 / (1 + 0.0001·d²) + + + Simplified inverse square law avoids harsh cutoffs + +#+END_EXPORT + +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 Sixth 3D. + +** Integration with the Render Pipeline +:PROPERTIES: +:CUSTOM_ID: render-pipeline-integration +:END: + +#+BEGIN_EXPORT html + + + + + + + + + + + Transform + compute lighting + + + + Shapes + + + Sort + + + Paint + use cached color + + + Blit + + + + + + + + +#+END_EXPORT + +Lighting is computed during *Phase 2* (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/sixth-3d/apidocs/eu/svjatoslav/sixth/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?** + +- Transform phase is *single-threaded* — no race conditions +- Lighting computed *once per polygon per frame* — not per pixel +- Result reused during multi-threaded paint phase — efficient + +** Using Shading in Your Scene +:PROPERTIES: +:CUSTOM_ID: using-shading +:END: + +**Adding light sources:** + +#+BEGIN_SRC java +import eu.svjatoslav.sixth.e3d.gui.ViewPanel; +import eu.svjatoslav.sixth.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.sixth.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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#setShadingEnabled(boolean)][setShadingEnabled(true)]] on a composite enables shading for all its +sub-polygons. + +** Performance Characteristics +:PROPERTIES: +:CUSTOM_ID: performance +:END: + +| Aspect | Cost | +|--------------+-------------------------------| +| Computation | Per polygon, not per pixel | +| Phase | Single-threaded (transform) | +| 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*: Single-threaded transform phase avoids synchronization +- *Pre-computed*: All 8 paint threads use 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. + +** Related Classes +:PROPERTIES: +:CUSTOM_ID: related-classes +:END: + +| Class | Purpose | +|-------+---------| +| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightingManager.html][LightingManager]] | Manages light sources and computes shading | +| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightSource.html][LightSource]] | Individual light with position, color, intensity | +| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] | Polygon shape with shading support | +| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html][AbstractCompositeShape]] | Composite shape with shading propagation | +| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/ViewPanel.html][ViewPanel]] | Provides access to LightingManager | \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Plane.java b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Plane.java index 48d526e..b208c87 100644 --- a/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Plane.java +++ b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Plane.java @@ -49,27 +49,70 @@ public class Plane { 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 edge1 = b.withSubtracted(a); - final Point3D edge2 = c.withSubtracted(a); - - final Point3D cross = edge1.cross(edge2); - - if (cross.getVectorLength() < EPSILON) { + final Point3D n = new Point3D(); + if (!computeNormal(a, b, c, n)) { throw new ArithmeticException( "Cannot create plane from collinear points: cross product is zero"); } - - final Point3D n = cross.unit(); - return new Plane(n, n.dot(a)); } diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewPanel.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewPanel.java index 1405aca..fe77551 100755 --- a/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewPanel.java +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewPanel.java @@ -383,11 +383,10 @@ public class ViewPanel extends Canvas { // === Render ONCE to offscreen buffer === // The offscreen bufferedImage is unaffected by BufferStrategy contentsRestored(), // so we only need to render once, then retry the blit if needed. - clearCanvasAllSegments(); rootShapeCollection.transformShapes(this, renderingContext); rootShapeCollection.sortShapes(); - // Phase 4: Paint segments in parallel + // Phase 4: Clear and paint segments in parallel final int height = renderingContext.height; final int segmentHeight = height / RenderingContext.NUM_RENDER_SEGMENTS; final SegmentRenderingContext[] segmentContexts = new SegmentRenderingContext[RenderingContext.NUM_RENDER_SEGMENTS]; @@ -408,6 +407,7 @@ public class ViewPanel extends Canvas { renderExecutor.submit(() -> { try { + clearSegmentPixels(segmentContexts[segmentIndex]); rootShapeCollection.paintShapes(segmentContexts[segmentIndex]); } finally { latch.countDown(); @@ -474,25 +474,18 @@ public class ViewPanel extends Canvas { } } - private void clearCanvasAllSegments() { + /** + * Clears a single segment's pixel area to the background color. + * Called by each render thread before painting shapes. + * + * @param ctx the segment rendering context with Y-bounds + */ + private void clearSegmentPixels(final SegmentRenderingContext ctx) { final int rgb = (backgroundColor.r << 16) | (backgroundColor.g << 8) | backgroundColor.b; final int width = renderingContext.width; - final int height = renderingContext.height; final int[] pixels = renderingContext.pixels; - - if (developerTools.renderAlternateSegments) { - // Clear only even segments (0, 2, 4, 6), leave odd segments with previous frame content - // This helps visualize what content would be overdrawn by the missing segment renders - final int segmentHeight = height / RenderingContext.NUM_RENDER_SEGMENTS; - for (int seg = 0; seg < RenderingContext.NUM_RENDER_SEGMENTS; seg += 2) { - final int minY = seg * segmentHeight; - final int maxY = (seg == RenderingContext.NUM_RENDER_SEGMENTS - 1) ? height : (seg + 1) * segmentHeight; - Arrays.fill(pixels, minY * width, maxY * width, rgb); - } - // Odd segments intentionally NOT cleared - retain previous frame's rendered content - } else { - Arrays.fill(pixels, 0, width * height, rgb); - } + + Arrays.fill(pixels, ctx.renderMinY * width, ctx.renderMaxY * width, rgb); } private void combineMouseResults(final SegmentRenderingContext[] segmentContexts) { diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java index 1e5033a..4aa3632 100644 --- a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java @@ -107,10 +107,6 @@ public class SolidPolygon extends AbstractCoordinateShape { *

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

*/ private Plane plane; - /** - * Flag indicating whether the plane has been computed. - */ - private boolean planeComputed = false; /** * The fill color of this polygon. */ @@ -502,13 +498,12 @@ public class SolidPolygon extends AbstractCoordinateShape { * @return the Plane containing this polygon */ public Plane getPlane() { - if (!planeComputed) { + if (plane == null) { plane = Plane.fromPoints( vertices.get(0).coordinate, vertices.get(1).coordinate, vertices.get(2).coordinate ); - planeComputed = true; } return plane; } @@ -524,12 +519,8 @@ public class SolidPolygon extends AbstractCoordinateShape { */ public void flip() { Collections.reverse(vertices); - for (final Vertex v : vertices) { - v.flip(); - } - if (planeComputed) { - plane.flip(); - } + for (final Vertex vertex : vertices) vertex.flip(); + if (plane != null) plane.flip(); } /** @@ -550,45 +541,6 @@ public class SolidPolygon extends AbstractCoordinateShape { return clone; } - /** - * Calculates the unit normal vector of this polygon. - * - * @param result the point to store the normal vector in - */ - private void calculateNormal(final Point3D result) { - if (vertices.size() < 3) { - result.x = result.y = result.z = 0; - return; - } - - final Point3D v0 = vertices.get(0).coordinate; - final Point3D v1 = vertices.get(1).coordinate; - final Point3D v2 = vertices.get(2).coordinate; - - final double ax = v1.x - v0.x; - final double ay = v1.y - v0.y; - final double az = v1.z - v0.z; - - final double bx = v2.x - v0.x; - final double by = v2.y - v0.y; - final double bz = v2.z - v0.z; - - double nx = ay * bz - az * by; - double ny = az * bx - ax * bz; - double nz = ax * by - ay * bx; - - final double length = Math.sqrt(nx * nx + ny * ny + nz * nz); - if (length > 0.0001) { - nx /= length; - ny /= length; - nz /= length; - } - - result.x = nx; - result.y = ny; - result.z = nz; - } - /** * Calculates the centroid (geometric center) of this polygon. * @@ -766,7 +718,13 @@ public class SolidPolygon extends AbstractCoordinateShape { // Compute lighting once during transform phase (single-threaded) if (shadingEnabled && renderingContext.lightingManager != null) { calculateCenter(cachedCenter); - calculateNormal(cachedNormal); + // Compute normal from first 3 vertices + Plane.computeNormal( + vertices.get(0).coordinate, + vertices.get(1).coordinate, + vertices.get(2).coordinate, + cachedNormal + ); renderingContext.lightingManager.computeLighting( cachedCenter, cachedNormal, color, shadedColor); } -- 2.20.1