diff --git a/.claude/skills/rokubot-examples/SKILL.md b/.claude/skills/rokubot-examples/SKILL.md index d72c7caa..fd3aa2a4 100644 --- a/.claude/skills/rokubot-examples/SKILL.md +++ b/.claude/skills/rokubot-examples/SKILL.md @@ -202,9 +202,9 @@ channel back to Home). | `canvas` | Demonstrates panning/scaling the whole game **canvas** (offset/scale), not an on-screen entity. On-screen text/rectangle are drawn on the separate UI layer, so the pan/scale effect isn't visible in a screenshot even though it's working — don't mistake that for a bug. | arrows = pan canvas, `info`/`instantreplay` = scale up/down, `play` = re-center | | `quickstart` | Minimal scaffold-template app: one white square, moves freely. Good smoke-test for "is the toolchain working." | arrows (any direction, free movement via `input.x`/`input.y`) | | `platformer` | "Jungle Adventure" side-scroller: 7 levels, the last a one-screen boss fight (a pop-up Venus fly trap that fires aimed fireballs and dies after 3 head stomps). `--param level=N` (1-based) skips the title screen straight to level N, e.g. `level=7` for the boss. | `left`/`right` run, `select` (OK) jumps, `back` pauses, `info` (options) toggles debug overlay + collider outlines | -| `rendererTest` | Categorized, menu-driven suite of `BGE.Renderer` demos - deliberately **not** built on `BGE.Game`/`GameScene` (see `CLAUDE.md`'s "Manually exercising the Renderer" note for the architecture and how to add a new demo). Grouped by category on an on-screen menu. Every demo shows an automatic timing line (fps / frame ms / update ms / draw ms / draw calls), so this is the place to *measure* a draw-cost question rather than reason about it - the `quad-fill-benchmark` demo times two fill approaches head to head in one run. | `up`/`down` = select in menu, `select` (OK) = run selected demo / demo-specific action, `back` = return to menu. Can also skip the menu entirely via a launch param: `rokubot launch dev --param demo=` (see `DemoList.bs` for valid ids) | +| `rendererTest` | Categorized, menu-driven suite of `BGE.Renderer` demos - deliberately **not** built on `BGE.Game`/`GameScene` (see `CLAUDE.md`'s "Manually exercising the Renderer" note for the architecture and how to add a new demo). Grouped by category on an on-screen menu. Every demo shows an automatic timing line (fps / frame ms / update ms / draw ms / draw calls), so this is the place to *measure* a draw-cost question rather than reason about it - the `quad-fill-benchmark` demo times two fill approaches head to head in one run. The Benchmarks category also has `occlusion-span-map-benchmark` (the occlusion span map's build/query cost) and `occlusion-scene-benchmark` (a scene of static occluders hiding objects, occlusion on vs off). | `up`/`down` = select in menu, `select` (OK) = run selected demo / demo-specific action, `back` = return to menu. Can also skip the menu entirely via a launch param: `rokubot launch dev --param demo=` (see `DemoList.bs` for valid ids) | | `hybrid` | Fixed (was previously broken — stale `getImage` call). Now: SceneGraph side plays a sample video; roScreen/BGE side is a ball-to-target minigame that switches back to SceneGraph on collision. | Ball game: arrows move the ball, reaching the green target switches to video. `back` (either side) toggles/quits | -| `terrain` | Demonstrates `BGE.DrawablePlane`/`SceneObjectPlane` (a Mode-7-style textured ground plane) — no vehicle/kart entity, just a camera driving around above the plane. Camera starts centered directly above the plane's origin. | `left`/`right` steer (yaw about world y), `up`/`down` drive forward/back along the current heading, `select` (OK) toggles the ground texture between the Mario Kart track image and a plain checkerboard, `options` (`*`) toggles the debug entity-details overlay, `back` quits | +| `terrain` | Demonstrates `BGE.DrawablePlane`/`SceneObjectPlane` (a Mode-7-style textured ground plane) — no vehicle/kart entity, just a camera driving around above the plane. Camera starts centered directly above the plane's origin. | `left`/`right` steer (yaw about world y), `up`/`down` drive forward/back along the current heading, `select` (OK) toggles the ground texture between the Mario Kart track image and a plain checkerboard, `options` (`*`) toggles the debug entity-details overlay, `back` quits. In the start scene (`WorldScene`) OK instead toggles occlusion culling, and holding Back 2s also shows the occlusion overlay (occluders and hidden objects); a static chapel stands in the scene and hides the trees behind it | | `rpg` | "Broadsword" (manifest title) - a *Link to the Past*-style top-down action RPG with a story: opens on a title screen (Continue/New Game), then a town with NPCs (Bram, Hilde's shop, a gate knight), a seven-room keep (goblins, a goblin king, the witch) and a sewer (rats), sword combat, hearts + coins HUD top-left. Deep links skip the title and disable saving: `--param scene=TownScene|CastleScene|SewerScene|GuardroomScene|StoreroomScene|BarracksScene|AntechamberScene|KingsHallScene|ThroneRoomScene|GoblinShowcaseScene` (the showcase now includes the witch) and/or `--param stage=0-8` (story checkpoint; stage 3 = knight wants a sword, 5 = met Mara in the keep, 6 = goblin king beaten, 7 = throne room with the witch's intro playing, 8 = town, witch defeated), `scene=CreditsScene`, plus `spawn=`, `flags=`, `items=`, `peaceful=`, `open=`. Real-time fighting is too fast to play via rokubot - hand that to the user. Black screenshots here have so far always been capture failures (retry; see gotchas). | arrows (or analog stick) move, `select` (OK) swings the sword / talks / confirms in panels, `play` opens the inventory, `info` (options) toggles debug overlays (colliders red, SolidWorld walls cyan), `back` quits or closes a panel (gamepad: A/B confirm/cancel, Y inventory, stick moves menu focus, B never quits; rokubot can't press gamepad buttons) | | `loading` | Asset-loading demo: a menu of scenes that each load in `GameScene.onLoad()`. "Large local assets" loads every model in `pkg:/models/` plus three large PNGs under a loading screen with a frame-time readout, then lists faces/sizes; "Custom loading screen" builds a 24x14 tile grid a row per frame under a strip-style loading screen. The debug UI (FPS etc.) is on. "Remote assets" downloads two images, a textured d20 .obj and a sound from `baseUrl`, plus `https://picsum.photos/200` (no extension - its type comes from the response's Content-Type; roUrlTransfer follows picsum's redirect) (default GitHub `main`; or serve `examples/loading/remote-assets` locally with `python3 -m http.server 8000 --directory examples/loading/remote-assets` plus `baseUrl=http://:8000/` - macOS may block incoming connections, so allow it when prompted, or use `npx http-server examples/loading/remote-assets -p 8000`) and lists each file as `network` or `cache`; a relaunch should show every remote file as `cache`. "Failure" shows failed loads. A "Clear download cache" menu button resets it. `--param scene=LocalAssetsScene|CustomScreenScene|RemoteAssetsScene|FailureScene` skips the menu. | `up`/`down` + `select` in the menu, `back` returns to the menu | | `visualnovel` | "The Mutiny": a short sci-fi visual novel (title with Continue/New Game/Credits and "Endings found", then a background, two portrait cards, a typewriter text box and choice buttons). Saves to the registry (`bge-visualnovel`), which `--deleteDevChannel` doesn't clear - to see a fresh install, break into the debugger and run `r = CreateObject("roRegistry") : r.Delete("bge-visualnovel") : r.Flush()`. `--param node=` (plus `flags=`/`items=` comma lists) starts at a node with saving off, e.g. `node=confrontation items=relayTap,navLog,accessRecord`. Pressing OK repeatedly plays through on the first choice each time and reaches an ending in about 60 presses. Ctrl-C into the debugger lags a second or two, so it's no good for catching a short loading screen. | `select` (OK) finishes typing / reads on / picks the focused choice, `up`/`down` move focus, `info` (`*`) or `up` while reading opens the backlog (`up`/`down` scroll, `back` closes), `back` returns to the title (quits from the title) | diff --git a/CLAUDE.md b/CLAUDE.md index f847e760..a97f7da2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,7 +103,7 @@ Entities are tracked in `Game.Entities` as `{ : { : GameEn - `GameEntity` is the base class every game object extends. It exposes empty lifecycle hooks meant to be overridden: `onCreate`, `onUpdate`, `onCollision`, `onDrawBegin`/`onDrawEnd`, `onInput`, `onECPKeyboard`/`onECPInput`, `onAudioEvent`, `onUrlEvent`, `onPause`/`onResume`, `onChangeScene`, `onGameEvent`, `onDestroy`. It owns `position`/`velocity`/`rotation`/`scale`, a `colliders` map, a `drawables` array, and a `tagsList` (`TagList`) for ad hoc tagging. - `Drawable.owner` may be `invalid` (issue #231) - a standalone drawable behaves as if owned by an entity at the origin with identity transform/scale 1. Engine code must read owner state through `Drawable`'s owner-safe accessors (`getGame()`, `log()`, `getOwnerTransformationMatrix()`/`getOwnerPosition()`/`getOwnerRotation()`/`getOwnerScale()`) rather than dereferencing `m.owner.*`/`m.drawable.owner.*` directly (the `isStatic` paths are the exception - `isStatic` implies an owner). See `OwnerlessDrawable.spec.bs`. -- `GameEntity.isOnScreen()` (aggregating `Drawable.isOnScreen()`, which aggregates `SceneObject.isCulled()`) reports whether an entity was actually drawn (not culled) last frame — a signal only, nothing in the engine skips an off-screen entity's own `onUpdate()`/collision checks automatically (a culled entity can still matter for gameplay, e.g. approaching from off-screen), so use it to opt in to skipping your own purely-visual per-frame work. See issue #75. +- `GameEntity.isOnScreen()` (true when `GameEntity.getVisibility()` is `visible` or `unknown`; `Drawable.getVisibility()`/`SceneObject.getVisibility()` roll up a `BGE.VisibilityState` - visible > occluded > offCamera > unknown - and `SceneObject.markSkippedOccluded()` is how the renderer reports an occluded skip) reports whether an entity was actually drawn (not culled) last frame — a signal only, nothing in the engine skips an off-screen entity's own `onUpdate()`/collision checks automatically (a culled entity can still matter for gameplay, e.g. approaching from off-screen), so use it to opt in to skipping your own purely-visual per-frame work. See issue #75. - **Two color formats, and they're easy to mix up**: `Drawable.color`/`Drawable.outlineRGBA` take packed **RGB** (`0xRRGGBB`, named colors in `BGE.ColorsRGB`) while every `Renderer.draw*` call takes packed **RGBA** (`0xRRGGBBAA`, named colors in `BGE.Colors`). Passing one where the other is expected gives a plausible-looking wrong color, not a type error. Note `BGE.getRandomColorRGB()` returns the RGB form (for drawables) and `getRandomColorRGBA()` the RGBA form. - `onInput(input as GameInput)` (`engine/GameInput.bs`) reports directional `x`/`y` in **world space** (standard math convention, +y is up) — pressing "up" gives `y: 1`, "down" gives `y: -1`. Only the renderer's final world-to-canvas/raster projection (see `Camera2d.worldPointToCanvasPoint`) flips y to screen/pixel space (+y down). Every example that does `velocity.y = input.y * speed` relies on this. - `GameScene` (in `engine/GameScene.bs`, renamed from `Room` in issue #227 - the class is `GameScene` to match `GameEntity`/`GameInput`, while the methods, fields and folders that refer to it just say "scene": `changeScene()`, `onChangeScene()`, `currentScene`, `Scenes/`) is itself just a `GameEntity` subclass representing one state of the game (title screen, menu, level) — the "current scene" is processed like any other entity but always first/last in update and collision passes. Scene changes go through `Game.changeScene()`/`defineScene()`, and non-persistent entities are destroyed on scene change (they get `onChangeScene()` but not `onDestroy()`). Scenes are defined once and reused: the scene being left (or reset) gets `onChangeScene(newScene)` then `onDestroy()`, so remove its `gameUi` widgets in either - a focusable `Button` left in `gameUi` stays registered with the `FocusManager` and keeps taking OK presses in the next scene (`examples/rpg`/`examples/visualnovel` `TitleScene`). "Scene" here is distinct from the renderer's `SceneObject`s (what gets drawn each frame) and from Roku's SceneGraph `GameScene` node. @@ -118,7 +118,8 @@ Entities are tracked in `Game.Entities` as `{ : { : GameEn - **`Camera2d` culling and pixel snapping** (issues #258/#255): `SceneObject.isPotentiallyOnScreen()` passes an object's bounding points to `Camera.isAnyInView()`. The base camera (and `Camera3d`) asks whether any single point is in view; `Camera2d` tests the points' bounding box against the view instead, so an image bigger than the view isn't culled when all four corners are off screen. `Camera2d.worldPointToCanvasPoint()` rounds to the nearest pixel (`Int(x + 0.5)`) rather than truncating, so edge-to-edge images never leave a 1px seam. - **Cull latching**: a `SceneObject` that the camera frustum rejects latches (`SceneObject.isCulled()`) so a stationary off-screen object doesn't re-run the frustum check every frame — only a genuine cull latches, not an attempted-but-failed draw, or a single bad frame becomes permanent (issue #48). That latch also covers a *deterministic* draw failure — one that depends only on this object's own position/orientation relative to the camera and canvas, so it'll keep failing every frame until something moves, same as a frustum cull. `SceneObject.isDeterministicDrawFailure()` is the extension point (default `false`, meaning "could be transient, keep retrying"); `SceneObjectBillboard` overrides it to cover a backface cull and an in-frustum-but-entirely-off-canvas rejection, both of which used to silently re-run their own (cheap but non-zero) checks every single frame with no latch at all (issue #73). An object `Renderer` skips because it's behind the camera (`negDistanceFromCamera >= 0`, on both the sorted and static-BSP paths) never reaches `draw()`, which is where `lastDrawMode`/`lastGeometryVersion` are recorded - so `Renderer` calls `SceneObject.markSkippedBehindCamera(camera)` instead, which records them and latches the object as culled. Without it, a stationary object behind the camera reads as changed every frame, forcing a transform recompute and a depth re-sort every frame (issue #308). -- **Depth sort** (issue #59): `Renderer.render()` skips its per-frame `sortBy` entirely when nothing that affects order changed that frame (`needsDepthSort`, set by `SceneObject.update()`'s existing movement/camera dirty-check plus `addSceneObject`/`removeSceneObject`, read via `Renderer.didSortLastFrame()`), and breaks ties between depths that quantize into the same `DEPTH_TIE_EPSILON`-wide bucket by the previous frame's relative order (`SceneObject.stableSortKey`/`getLastSortIndex`) rather than whatever `roArray.SortBy` happens to produce for jittering floats — this is bucket quantization (`Int(depth / DEPTH_TIE_EPSILON)`), not a true epsilon-tolerance comparison, so two depths closer together than the epsilon can still straddle a bucket boundary and swap order — this is still a whole-object painter's sort, just a cheaper and more stable one, not new visibility behavior. Separately, `BGE.DepthSort` (`renderer/DepthSortHelpers.bs`) computes per-frame **overlap clusters**, gated behind `Renderer.computeOverlapClusters` (off by default): a broad-phase **sort-and-sweep** (`sortByMinXIncremental`/`sweepForCandidatePairIndexes` — an incremental insertion-sort by screen-space minX plus an active-set sweep, replacing an original all-pairs `O(n^2)` scan that measured a ~70% FPS regression at ~176 scene objects on real hardware) followed by a narrow-phase SAT test (`hullsOverlap`) rejects false positives like a long diagonal wall's AABB spuriously overlapping a nearby-but-separate model; surviving pairs union-find into clusters via `groupIntoClusters()`, exposed read-only as `Renderer.getOverlapClusters()`. A `SceneObject` opts out of candidacy entirely via `participatesInOverlapDetection()` (default `true`; `SceneObjectLine` overrides `false` — a 2-point line hull can never pass the narrow phase's hull-validity check anyway, so this is pure broad-phase cost avoidance for a computation guaranteed to reject it, not a tolerated visual approximation). A multi-member cluster's members draw via `getPrimitiveCount()`/`getPrimitiveDepth(index)`/`drawPrimitive(rendererObj, index)` (base-class defaults: 1 primitive, this object's own depth, delegates to the normal cached `performDraw()` — correct as-is for every billboard type, since a billboard is always one piece regardless of clustering) instead of the normal whole-object `draw()` call; `Renderer` collects every deferred cluster member's primitives into one combined list, sorts it once by depth, and draws interleaved (`addPendingClusterDraw`/`drawPendingClusterPrimitives`). `SceneObjectModel` overrides all three to expose its per-face list (`m.modelCanvasFaces`, already rebuilt per-frame by `updateCanvasPosition()` reflecting the current draw mode's backface culling) and draws each face directly via a shared `drawFaceToCanvas()` helper, bypassing its own whole-model temp-bitmap cache entirely (that cache aggregates faces in an order that can't represent cross-object interleaving). A solo cluster (no other object's bounds overlap it) draws exactly as before — zero behavior/perf change for the common case. See `specs/2026-08-15-depth-sort-design.md` and `specs/2026-08-16-depth-sort-plan-2-design.md` for the full design (including why occlusion culling is a separate future issue, #108) and `examples/depthsort` (`TieBreakScene`, `ClusterVisualizerScene`) for a runnable demo. Issue #107 — the case this whole-object painter's sort still can't represent, a static quad a dynamic object sits partway through the depth range of — is BSP, and is implemented separately; see the "Static geometry BSP" bullet below. +- **Depth sort** (issue #59): `Renderer.render()` skips its per-frame `sortBy` entirely when nothing that affects order changed that frame (`needsDepthSort`, set by `SceneObject.update()`'s existing movement/camera dirty-check plus `addSceneObject`/`removeSceneObject`, read via `Renderer.didSortLastFrame()`), and breaks ties between depths that quantize into the same `DEPTH_TIE_EPSILON`-wide bucket by the previous frame's relative order (`SceneObject.stableSortKey`/`getLastSortIndex`) rather than whatever `roArray.SortBy` happens to produce for jittering floats — this is bucket quantization (`Int(depth / DEPTH_TIE_EPSILON)`), not a true epsilon-tolerance comparison, so two depths closer together than the epsilon can still straddle a bucket boundary and swap order — this is still a whole-object painter's sort, just a cheaper and more stable one, not new visibility behavior. Separately, `BGE.DepthSort` (`renderer/DepthSortHelpers.bs`) computes per-frame **overlap clusters**, gated behind `Renderer.computeOverlapClusters` (off by default): a broad-phase **sort-and-sweep** (`sortByMinXIncremental`/`sweepForCandidatePairIndexes` — an incremental insertion-sort by screen-space minX plus an active-set sweep, replacing an original all-pairs `O(n^2)` scan that measured a ~70% FPS regression at ~176 scene objects on real hardware) followed by a narrow-phase SAT test (`hullsOverlap`) rejects false positives like a long diagonal wall's AABB spuriously overlapping a nearby-but-separate model; surviving pairs union-find into clusters via `groupIntoClusters()`, exposed read-only as `Renderer.getOverlapClusters()`. A `SceneObject` opts out of candidacy entirely via `participatesInOverlapDetection()` (default `true`; `SceneObjectLine` overrides `false` — a 2-point line hull can never pass the narrow phase's hull-validity check anyway, so this is pure broad-phase cost avoidance for a computation guaranteed to reject it, not a tolerated visual approximation). A multi-member cluster's members draw via `getPrimitiveCount()`/`getPrimitiveDepth(index)`/`drawPrimitive(rendererObj, index)` (base-class defaults: 1 primitive, this object's own depth, delegates to the normal cached `performDraw()` — correct as-is for every billboard type, since a billboard is always one piece regardless of clustering) instead of the normal whole-object `draw()` call; `Renderer` collects every deferred cluster member's primitives into one combined list, sorts it once by depth, and draws interleaved (`addPendingClusterDraw`/`drawPendingClusterPrimitives`). `SceneObjectModel` overrides all three to expose its per-face list (`m.modelCanvasFaces`, already rebuilt per-frame by `updateCanvasPosition()` reflecting the current draw mode's backface culling) and draws each face directly via a shared `drawFaceToCanvas()` helper, bypassing its own whole-model temp-bitmap cache entirely (that cache aggregates faces in an order that can't represent cross-object interleaving). A solo cluster (no other object's bounds overlap it) draws exactly as before — zero behavior/perf change for the common case. See `specs/2026-08-15-depth-sort-design.md` and `specs/2026-08-16-depth-sort-plan-2-design.md` for the full design (occlusion culling is covered by the "Occlusion culling" bullet below) and `examples/depthsort` (`TieBreakScene`, `ClusterVisualizerScene`) for a runnable demo. Issue #107 — the case this whole-object painter's sort still can't represent, a static quad a dynamic object sits partway through the depth range of — is BSP, and is implemented separately; see the "Static geometry BSP" bullet below. +- **Occlusion culling** (issues #108/#342, spec `specs/2026-10-16-occlusion-culling-design.md`): `Drawable.isOccluder` (opaque only; static owner; an oriented draw mode - `oriented`/`orientedDrawBackFace`/`solid`/`solidDrawBackFace`; `Camera3d` only; static models occlude too; an ownerless `SceneObject` calls `Renderer.markOccluder()`) makes a drawable hide what's behind it (re-checked every frame by `Renderer.syncOccluderFlag()`, so it can be toggled at runtime). `Renderer.updateOcclusion()` runs right after `updateSceneObjects()` and costs nothing with no occluders, with `occlusionCullingEnabled = false`, or under `Camera2d`. It builds a span map (`renderer/occlusion/OcclusionSpanMap.bs`): per horizontal band (`occlusionBandCount`, 45) the merged covered x-ranges plus the farthest depth of each, from the `maxOccluders` (32) largest-on-screen occluder polygons. An occludee is hidden only if every bounding point projects and its AABB, inflated by `occlusionMargin` (2.0 px), is covered in every band it touches by nearer spans - anything uncertain draws. Before that full test, `Renderer.mayBeHiddenByOccluders()` rules an object out by its depth position (`getDepthPosition()`, assumed inside what it draws - a wrong assumption only means it draws): nearer than every span (`OcclusionSpanMap.getNearestDepth()`), or that point not under a nearer span (`isPointCovered()`). Occlusion projects through `Camera3d.projectPointsWithDepth()` (same results as `worldPointToCanvasPoint()`/`distanceFromCameraFront()`, much cheaper per point), model occluders cache their world-space panels until they move (and rebuild their local panels after `invalidateGeometry()`, e.g. a changed `DrawableModel.model`), and an oriented image/rectangle/circle is tested by its `worldPoints`. `Renderer.getOcclusionMsLastFrame()`/`getOcclusionTestsLastFrame()` measure the pass. On a Roku Ultra in `examples/terrain` it costs ~7ms per moving frame (19ms before these cuts) and is about break-even there, since the trees it hides are single cheap blits. An occluder takes the usual fast paths (temp-bitmap reuse, the image fast path, model per-face reuse/whole-model stretch, coarse triangle fills); instead its claim shrinks by the worst error of whatever may run this frame (`SceneObject.getOccluderInset()`, `BGE.Occlusion.insetConvexPolygon()` in `occlusion/ConvexInset.bs`): only while it moves relative to the camera, by the reuse threshold (`getTempBitmapThreshold()`, 16, a model's 64), a model face's drift (`maxFaceDriftDistance` x (1 + sqrt 2)) and the band step (`Renderer.getQuickDrawFillError()`); an image taking the fast path claims the rectangle that path paints (`getRotatedQuadPaintedCorners()`), 1px in. A rectangle whose triangle could be skipped as tiny, or a model panel with a source corner inside it (`OccluderPanel.hasInnerVertices`), claims nothing while moving. A change in the inset or quality level rebuilds the map, so the claim grows back the frame motion stops. That is `Renderer.occluderDrawPolicy`'s default, `BGE.OccluderDrawPolicy.fastShrink`; `exact` makes occluders skip every fast path and claim their exact outline, and `fastExactClaim` keeps the fast paths but claims the exact outline anyway (can glitch at edges while moving). A policy change bumps `qualityVersion`, so cached drawings redraw once. A `fastShrink` occluder model's per-face raster reuse only happens while it moves; other models reuse on still frames too. `examples/terrain` deep link `--param profile=1` (`TerrainProfiler.bs`) flies a fixed path once per configuration (off, then each policy) at a pinned quality level and prints `[terrain profile]` lines, including occlusion ms and full tests per frame. A hidden object skips `draw()` via `SceneObject.markSkippedOccluded()`, which forces a canvas recompute when it returns; screen-aligned fixed-size modes, `SceneObjectText` (its size is only known once it draws) and `SceneObjectParticle` are never occluded, nor is an object latched as culled. #342: an oriented model/polygon takes its depth from its own centre, so it sorts and occludes consistently. `BGE.VisibilityState` (`unknown`/`visible`/`occluded`/`offCamera`) comes from `SceneObject.getVisibility()` and rolls up through `Drawable`/`GameEntity` (visible > occluded > offCamera > unknown); `GameEntity.isOnScreen()` is `visible` or `unknown` (`Drawable.isOnScreen()` is gone). `Renderer.getOccludedCountLastFrame()` and `Game.debugDrawOcclusion(enabled)` help tuning. See `rendererTest`'s `occlusion-span-map-benchmark`/`occlusion-scene-benchmark` demos and `examples/terrain`'s chapel (OK toggles occlusion in `WorldScene`; holding Back 2s also shows the overlay). - `Renderer` (`renderer/Renderer.bs`) wraps an `ifDraw2D` surface (a `Canvas`'s bitmap) and owns the list of `SceneObject`s to draw each frame, plus a `Camera` (`Camera2d` or `Camera3d`, under `renderer/cameras/`), a `TriangleCache` (caches rasterized triangle bitmaps — triangle drawing is otherwise expensive), and a `ScratchBitmapPool` (`ScratchBitmap.bs`) for reusable off-screen bitmaps. There is one `Renderer` for the game canvas and a separate one for the UI canvas (`Game.canvas.renderer` / `Game.uiCanvas.renderer`). `Renderer.resources` holds its own one-off generated assets (`rightTriangle`, `circle`) — `circle` is built lazily via `getCircleResource()`/`getCircleRegionWithId()` on first use rather than eagerly for every `Renderer`, since most never draw a circle at all and rasterizing one is expensive enough to matter across the whole test suite's worth of `Game` constructions. `Renderer.drawCircle()`/`drawCircleTo()` expose this as a standalone draw call (scales the circle resource via `drawScaledObjectTo`), the same way `drawRectangle()` wraps a native `DrawRect`. - **Flat triangle fills** (`Renderer.drawTriangleTo()`, issues #119/#332): a triangle whose shorter bounding-box side is at most `TRIANGLE_SPAN_MAX_BANDS` (16) is filled with 1px `DrawRect` bands (`drawTriangleAsSpansTo()`), anything bigger with `drawTriangleExactTo()` (two `DrawTransformedObject` calls of the right-triangle texture, a flat ~0.35ms on a Roku Ultra whatever the size, but it leaves dotted seams along its internal edges). Both break even at ~16 on device and simulator alike (rendererTest's `triangle-fill-check`). With `allowQuickDraw` (the object moved), a mid-sized triangle gets coarser bands (`triangleQuickDrawStep`) and a tiny one is skipped; both bump `getLossyTriangleFillCount()`, which `SceneObjectRectangle`/`SceneObjectPolygon` turn into `didFastDraw` so the fill is redrawn exactly once still. Triangles sharing an edge must band the same way (`bandAcrossY`, from `shouldBandAcrossY()` over the whole quad/polygon) - coarse bands sit on a shared grid but don't line up across directions. The old fanned-line fill (`drawTriangleAsRaysTo()`) stripes and is no longer used. `Renderer` turns on `SetAlphaEnable(true)` on its draw target - on a surface without it, every temp bitmap's transparent corners are copied as opaque black (device, simulator and brs-engine alike). - `SceneObject` subclasses (`sceneObjects/SceneObject*.bs`) represent what actually gets drawn: `SceneObjectImage`, `SceneObjectBillboard`, `SceneObjectLine`, `SceneObjectPolygon`, `SceneObjectRectangle`, `SceneObjectText`, `SceneObjectCircle`, `SceneObjectModel`, `SceneObjectSkybox`, `SceneObjectPlane`, `SceneObjectParticle`. `SceneObjectParticle` is the one documented exception to "one `SceneObject` per `Drawable`" in this codebase — a single instance draws its whole `DrawableParticles` emitter's live particles in one batched loop, specifically to avoid `addSceneObject`/`removeSceneObject` churn defeating depth-sort's skip-optimization during continuous emission; see `specs/2026-08-18-particle-system-design.md`. Each `SceneObjectDrawMode` (`matchCamera`, `directToCamera`, `directScaled`, `oriented`/`orientedDrawBackFace`, `wireFrame`/`wireFrameDrawBackFace`, `solid`/`solidDrawBackFace`) controls how an object reacts to camera rotation/perspective — this is what gives the 2D-renderer-based engine its pseudo-3D/billboard capability (see `examples/3d`). A texture-backed billboard (`SceneObjectImage`, `SceneObjectCircle`) pays a real per-pixel warp cost (`drawPinnedCorners`) in the oriented/solid modes that a flat-fill one (`SceneObjectRectangle`, `SceneObjectPolygon`) doesn't — this is proportional and expected, not a bug, confirmed by comparing `examples/3d`'s `RectanglesScene` against `CirclesScene` on a simulator (where oriented-mode rendering in general is markedly slower than on real hardware). `SceneObjectCircle` skips that cost whenever it safely can — see the `isApproximatelyRotatedRectangle` bullet above (issue #105). Plain `oriented` backface-culls a quad whose winding faces away from the camera — a wall of a box the camera sits *inside* needs `orientedDrawBackFace` on its inward-facing surface, or it silently draws nothing (`examples/collisions3d`'s `Wall.bs`, its wall images now re-enabled now that static geometry BSP — see below — orders them correctly against the balls inside). diff --git a/docs/drawables-and-scene-objects.md b/docs/drawables-and-scene-objects.md index 70c361c1..1f24ebae 100644 --- a/docs/drawables-and-scene-objects.md +++ b/docs/drawables-and-scene-objects.md @@ -240,6 +240,10 @@ and perspective: This is what gives a fundamentally 2D-raster engine its pseudo-3D/billboard capability (see `examples/3d`) - `examples/rendererTest` has a runnable demo per mode (`DemoList.bs`). +For a model, a face's front is the side it's wound counter-clockwise from - the usual .obj/.stl +convention, so its outside. The modes without `DrawBackFace` draw only faces turned toward the +camera, and `isShaded` lights each face by how squarely it faces the camera. + `matchCamera`, `directToCamera` and `directScaled` are the **screen-aligned** (billboard) modes - `isScreenAlignedDrawMode()` - and they keep an object square to the screen instead of turning it in 3D. `directScaled` is the Doom-sprite one: it faces the camera and its on-screen size comes @@ -282,6 +286,90 @@ A ground plane always reaches the farthest any quality level would draw objects past its edge, and the horizon doesn't move when the quality level changes. Past the default distance, the ground's texture gets a little softer instead of costing more memory. +## Occluders and visibility + +A big opaque thing in a 3D scene (a building, a wall, a hill) hides whatever stands behind it. +Mark its drawable `isOccluder: true` and the renderer stops drawing everything it fully hides: + +```brighterscript +class Chapel extends BGE.GameEntity + + override sub onCreate(args as roAssociativeArray) + m.isStatic = true ' an occluder's owner never moves + model = m.game.get3dModel("chapel") + m.addDrawable("chapel", new BGE.DrawableModel(m, model, { + isOccluder: true, + drawMode: BGE.SceneObjectDrawMode.solid + })) + end sub + +end class +``` + +Game code can ask what happened to an entity last frame, for example to skip purely visual +work for a tree nobody can see: + +```brighterscript +override sub onUpdate(dt as float) + if not m.isOnScreen() + return ' hidden or off camera: skip the sway animation + end if + m.animateSway(dt) +end sub + +' Or tell the reasons apart: +if m.getVisibility() = BGE.VisibilityState.occluded + ' something solid is in front of it +end if +``` + +`getVisibility()` (on `GameEntity`, `Drawable` and `SceneObject`) returns a `BGE.VisibilityState`: +`visible` (drawn), `occluded` (hidden behind an occluder), `offCamera` (outside the camera's view) +or `unknown` (no drawables, or not drawn yet). An entity with several drawables reports the +most visible one, in the order visible > occluded > offCamera > unknown. `isOnScreen()` is true for +`visible` and `unknown`. Nothing in the engine skips an entity's `onUpdate()` or collisions for +you; this is only a signal. + +To see what's going on, call `game.debugDrawOcclusion(true)` (it shades the canvas areas the +occluders cover and draws a red box around each hidden object), and read `renderer.getOccludedCountLastFrame()`. The `terrain` example has a +static chapel that hides the trees behind it, and OK toggles occlusion in its `WorldScene`. + +A few things the renderer enforces, which are easy to trip over: + +- The drawable must be **fully opaque** (an image with no transparent pixels, a model with no + translucent colors). The engine can't check this, so anything behind a see-through occluder + would vanish. +- The owner must be `isStatic`, and the drawable's draw mode must be an oriented one + (`oriented`, `orientedDrawBackFace`, `solid`, `solidDrawBackFace`). Screen-aligned modes + (`matchCamera`, `directToCamera`, `directScaled`) are ignored as occluders, and so is any + occluder under a `Camera2d`. +- `isOccluder` can be turned on or off at any time, e.g. when a wall becomes see-through. +- A standalone `SceneObject` with no owner registers itself with `renderer.markOccluder()`. +- Occlusion is conservative: an object is only hidden when it is clearly behind the occluders, + with a small safety margin, so a borderline object still draws. Screen-aligned fixed-size + objects, text and particles are never hidden. +- An occluder draws like anything else, cached bitmaps and coarse fills included. While it (or + the camera) moves, the area it claims to cover shrinks by the largest error those shortcuts + can make, so it rarely hides something it doesn't actually paint over (a non-convex model + can still briefly over-claim near its silhouette). An object near an + occluder's edge can therefore stay drawn while the camera moves, and is hidden again once it + stops. +- `renderer.occluderDrawPolicy` changes that trade-off. The default, + `BGE.OccluderDrawPolicy.fastShrink`, is the behaviour above. `exact` makes occluders always + draw exactly and claim their whole outline (slower to draw, hides the most). + `fastExactClaim` keeps the shortcuts but still claims the whole outline, so an object just + behind an occluder's edge can vanish for a few frames while the camera moves. +- Tuning knobs on the `Renderer`: `occlusionCullingEnabled` (default `true`), `occlusionBandCount` + (45 horizontal bands), `maxOccluders` (32, the largest on screen are kept) and `occlusionMargin` + (2.0 pixels of safety around each object). With no occluders registered the whole feature + costs nothing. +- Occlusion has its own cost: while the camera moves, the occluders are re-projected and each + object is re-judged every frame. It pays off when what it hides is expensive to draw (warped + textures, models), and barely breaks even when the hidden objects are cheap sprites. + `renderer.getOcclusionMsLastFrame()` and `renderer.getOcclusionTestsLastFrame()` (objects + that needed the full test) show what it costs you; compare frame times with + `occlusionCullingEnabled` on and off. + ## How `Renderer.render()` actually draws a frame `Renderer.render()` (`engine/renderer/Renderer.bs`) runs once per frame, per canvas (there's a @@ -295,8 +383,8 @@ separate `Renderer` for the game canvas and the UI canvas). It does three things whose `negDistanceFromCamera < 0` (i.e. in front of the camera). Planes are drawn first because they're meant to be a ground/floor - drawing them before anything else means normal painter's-algorithm depth sorting for everything else (which _does_ sort against the camera - distance) still looks correct sitting on top of the plane. This is a `TODO` for proper occlusion - culling in the engine's own comments, not a settled final design. + distance) still looks correct sitting on top of the plane. Objects hidden behind an occluder + are skipped before this point (see "Occluders and visibility"). ### Per-object update (`SceneObject.update`/`draw`) diff --git a/examples/rendererTest/src/source/DemoList.bs b/examples/rendererTest/src/source/DemoList.bs index d006e0c6..78f3648a 100644 --- a/examples/rendererTest/src/source/DemoList.bs +++ b/examples/rendererTest/src/source/DemoList.bs @@ -9,6 +9,22 @@ ' @return {object[]} array of {id, category, name, create} function GetDemoList() as object return [ + { + id: "occlusion-span-map-benchmark", + category: "Benchmarks", + name: "Occlusion Span Map Benchmark", + create: function() as RendererTest + return new OcclusionSpanMapBenchmark() + end function + }, + { + id: "occlusion-scene-benchmark", + category: "Benchmarks", + name: "Occlusion Scene Benchmark", + create: function() as RendererTest + return new OcclusionSceneBenchmark() + end function + }, { id: "texture-decode-benchmark", category: "Loading", diff --git a/examples/rendererTest/src/source/Tests/OcclusionSceneBenchmark.bs b/examples/rendererTest/src/source/Tests/OcclusionSceneBenchmark.bs new file mode 100644 index 00000000..1b22f45b --- /dev/null +++ b/examples/rendererTest/src/source/Tests/OcclusionSceneBenchmark.bs @@ -0,0 +1,212 @@ +' Times Renderer occlusion culling (issue #108) on an occlusion-heavy scene: three large +' static wall rectangles registered as occluders, with 60 oriented Image billboards +' (expensive texture warps) scattered behind and beside them, under an orbiting camera. +' +' Four phases: occlusion on/off x camera moving/still. Each gets its own identical +' off-screen renderer, driven by frame index so every phase draws the same frames, one +' frame per update() so nothing blocks long enough to trip the Roku watchdog. A live view +' follows; OK toggles the occlusion debug overlay on it. +class OcclusionSceneBenchmark extends RendererTest + + private imageCount = 60 + private warmupFrames = 5 + private timedFrames = 60 + private surfaceWidth = 1280 + private surfaceHeight = 720 + private thumbWidth = 320 + private thumbHeight = 180 + private safeX = 140 + private lineHeight = 26 + private font as roFont + private texture as roBitmap + + private phaseNames as string[] = ["Occlusion on, camera moving", "Occlusion off, camera moving", "Occlusion on, camera still", "Occlusion off, camera still"] + private phaseOcclusion as boolean[] = [true, false, true, false] + private phaseMoving as boolean[] = [true, true, false, false] + private phaseIndex = 0 + private frameIndex = 0 + private phaseMs as float[] = [0, 0, 0, 0] + private phaseDrawCalls as integer[] = [0, 0, 0, 0] + private phaseOccluded as integer[] = [0, 0, 0, 0] + private phaseOcclusionMs as integer[] = [0, 0, 0, 0] + private phaseTests as integer[] = [0, 0, 0, 0] + private phaseThumbs as roBitmap[] = [] + + private sceneRenderer as BGE.Renderer + private sceneBitmap as roBitmap + private camera as BGE.Camera3d + private isLive = false + private liveFrame = 0 + + sub new() + super("Occlusion Scene Benchmark") + end sub + + override sub setup(renderer as BGE.Renderer) + m.font = CreateObject("roFontRegistry").GetDefaultFont(20, false, false) + m.texture = m.createTexture() + m.startScene(true) + end sub + + override sub onInput(buttonName as string) + if buttonName = "OK" and m.isLive + m.sceneRenderer.showOcclusionDebug = not m.sceneRenderer.showOcclusionDebug + end if + end sub + + override sub update(dt as float) + if m.sceneRenderer = invalid + return + end if + + if m.isLive + m.renderFrame(m.liveFrame, true) + m.liveFrame++ + return + end if + + timer = CreateObject("roTimespan") + m.renderFrame(m.frameIndex, m.phaseMoving[m.phaseIndex]) + ms = timer.TotalMilliseconds() + + if m.frameIndex >= m.warmupFrames + m.phaseMs[m.phaseIndex] += ms + m.phaseDrawCalls[m.phaseIndex] += m.sceneRenderer.getDrawCallsLastFrame() + m.phaseOccluded[m.phaseIndex] += m.sceneRenderer.getOccludedCountLastFrame() + m.phaseOcclusionMs[m.phaseIndex] += m.sceneRenderer.getOcclusionMsLastFrame() + m.phaseTests[m.phaseIndex] += m.sceneRenderer.getOcclusionTestsLastFrame() + end if + m.frameIndex++ + + if m.frameIndex >= m.warmupFrames + m.timedFrames + print `[Occlusion scene benchmark] ${m.resultLine(m.phaseIndex)}` + thumb = CreateObject("roBitmap", {width: m.thumbWidth, height: m.thumbHeight, alphaEnable: false}) + thumb.DrawScaledObject(0, 0, m.thumbWidth / m.surfaceWidth, m.thumbHeight / m.surfaceHeight, m.sceneBitmap) + thumb.Finish() + m.phaseThumbs.push(thumb) + m.phaseIndex++ + m.frameIndex = 0 + ' Free this phase's scratch pool before the next one allocates its own. + m.sceneRenderer = invalid + m.sceneBitmap = invalid + RunGarbageCollector() + if m.phaseIndex < m.phaseNames.count() + m.startScene(m.phaseOcclusion[m.phaseIndex]) + else + m.isLive = true + m.startScene(true) + end if + end if + end sub + + ' Draws one frame; frame index (not dt) drives the camera so phases match. + private sub renderFrame(frame as integer, moving as boolean) + angle = 0.4 + if moving + angle = sin(frame * 0.03) * 0.6 + end if + m.camera.position = BGE.Math.VectorOps.create(sin(angle) * 600, 40, -cos(angle) * 600) + m.camera.setTarget(BGE.Math.VectorOps.create(0, 0, 100)) + m.sceneRenderer.setupCameraForFrame() + m.sceneRenderer.resetDrawCallCounter() + m.sceneRenderer.render() + end sub + + private sub startScene(occlusion as boolean) + m.sceneBitmap = CreateObject("roBitmap", {width: m.surfaceWidth, height: m.surfaceHeight, alphaEnable: false}) + m.sceneRenderer = new BGE.Renderer(m.sceneBitmap) + m.sceneRenderer.occlusionCullingEnabled = occlusion + m.camera = new BGE.Camera3d() + m.sceneRenderer.setCamera(m.camera) + m.buildWalls() + m.buildImages() + end sub + + private sub buildWalls() + colors = [BGE.ColorsRGB.Navy, BGE.ColorsRGB.Maroon, BGE.ColorsRGB.Green] + for i = 0 to 2 + wall = new BGE.DrawableRectangle(invalid, 280, 260, { + offset: BGE.Math.VectorOps.create(-290 + 290 * i, 0, 0), + color: colors[i], + outlineRGBA: BGE.ColorsRGB.White, + drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace + }) + wall.setAnchor(0.5, 0.5) + sceneObj = wall.addToRenderer(m.sceneRenderer) + m.sceneRenderer.markStatic(sceneObj) + m.sceneRenderer.markOccluder(sceneObj) + end for + end sub + + ' Behind (z > 0) and beside the walls, at fixed pseudo-random spots so every phase matches. + private sub buildImages() + for i = 0 to m.imageCount - 1 + region = CreateObject("roRegion", m.texture, 0, 0, 64, 64) + region.SetPretranslation(-32, -32) + image = new BGE.Image(invalid, region, { + offset: BGE.Math.VectorOps.create(m.spread(i, 1.7) * 480, m.spread(i, 3.1) * 130, 40 + (m.spread(i, 5.3) + 1) * 150), + scale: BGE.Math.VectorOps.create(1.5, 1.5, 1), + drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace + }) + image.addToRenderer(m.sceneRenderer) + end for + end sub + + ' Deterministic value in [-1, 1]. + private function spread(index as integer, factor as float) as float + return sin((index + 1) * factor * 12.9898) + end function + + private function createTexture() as roBitmap + bmp = CreateObject("roBitmap", {width: 64, height: 64, alphaEnable: true}) + bmp.Clear(&hFFC040FF) + for i = 0 to 7 + for j = 0 to 7 + if (i + j) mod 2 = 0 + bmp.DrawRect(i * 8, j * 8, 8, 8, &h202020FF) + end if + end for + end for + bmp.Finish() + return bmp + end function + + private function resultLine(index as integer) as string + frames = m.timedFrames + return `${m.phaseNames[index]}: ${m.phaseMs[index] / frames} ms/frame, ${m.phaseDrawCalls[index] / frames} draw calls, ${m.phaseOccluded[index] / frames} occluded, occlusion ${m.phaseOcclusionMs[index] / frames} ms, ${m.phaseTests[index] / frames} tests` + end function + + override sub draw(renderer as BGE.Renderer) + if m.isLive and m.sceneBitmap <> invalid + renderer.drawObject(0, 0, m.sceneBitmap) + end if + + ' y=80 clears main.bs's title header (see QuadFillBenchmark.bs). + y = 80 + for i = 0 to m.phaseNames.count() - 1 + if i < m.phaseIndex + line = m.resultLine(i) + else if i = m.phaseIndex + line = `${m.phaseNames[i]}: running (${m.frameIndex}/${m.warmupFrames + m.timedFrames})` + else + line = `${m.phaseNames[i]}: waiting` + end if + renderer.drawRectangle(m.safeX - 10, y - 2, 1000, m.lineHeight - 2, &h000000C0) + renderer.drawText(line, m.safeX, y, BGE.Colors.White, m.font) + y += m.lineHeight + end for + + if m.isLive + renderer.drawRectangle(m.safeX - 10, y - 2, 1000, m.lineHeight - 2, &h000000C0) + renderer.drawText(`live: ${m.sceneRenderer.getOccludedCountLastFrame()} occluded, OK = toggle debug overlay`, m.safeX, y, BGE.Colors.Yellow, m.font) + else + x = m.safeX + y += 16 + for i = 0 to m.phaseThumbs.count() - 1 + renderer.drawObject(x, y, m.phaseThumbs[i]) + x += m.thumbWidth + 10 + end for + end if + end sub + +end class diff --git a/examples/rendererTest/src/source/Tests/OcclusionSpanMapBenchmark.bs b/examples/rendererTest/src/source/Tests/OcclusionSpanMapBenchmark.bs new file mode 100644 index 00000000..0d3cca1e --- /dev/null +++ b/examples/rendererTest/src/source/Tests/OcclusionSpanMapBenchmark.bs @@ -0,0 +1,265 @@ +' Times BGE.OcclusionSpanMap (issue #108) on random convex canvas-space occluders and +' checks every "hidden" verdict against a brute-force sample of the polygons (falseHides +' must be 0). +' +' One config runs per few frames, so nothing blocks long enough to trip the Roku +' watchdog. A visual check follows: blue = occluders, red box = judged hidden, green = +' visible. OK regenerates it. +class OcclusionSpanMapBenchmark extends RendererTest + + private width = 1280 + private height = 720 + private bandCount = 45 + private occludeeCount = 300 + private buildsPerFrame = 5 + private framesPerConfig = 4 + private checkBudgetMs = 50 + private pendingChecks as integer[] = [] + private occluderCounts as integer[] = [8, 32, 128] + private configIndex = 0 + private frameIndex = 0 + private results as string[] = [] + private font as roFont + + private occluders as BGE.OccluderPolygon[] = [] + private occludees as object[] = [] + private buildMs = 0 + private queryMs = 0 + private hiddenCount = 0 + private falseHides = 0 + + private visualHidden as boolean[] = [] + private visualReady = false + private visualLine = "" + + sub new() + super("Occlusion Span Map Benchmark") + end sub + + override sub setup(renderer as BGE.Renderer) + m.font = CreateObject("roFontRegistry").GetDefaultFont(20, false, false) + m.startConfig() + end sub + + override sub onInput(buttonName as string) + if buttonName = "OK" and m.visualReady + m.buildVisual() + end if + end sub + + private sub startConfig() + m.occluders = m.makeOccluders(m.occluderCounts[m.configIndex], 10, 50) + m.occludees = m.makeOccludees(m.occludeeCount, 0, 100) + m.buildMs = 0 + m.queryMs = 0 + m.hiddenCount = 0 + m.falseHides = 0 + m.frameIndex = 0 + m.pendingChecks = [] + end sub + + override sub update(dt as float) + if m.configIndex >= m.occluderCounts.count() + return + end if + if m.frameIndex < m.framesPerConfig + spanMap = new BGE.OcclusionSpanMap(m.width, m.height, m.bandCount) + timer = CreateObject("roTimespan") + for i = 1 to m.buildsPerFrame + spanMap.build(m.occluders) + end for + m.buildMs += timer.TotalMilliseconds() + + timer.Mark() + hidden = [] + for each aabb in m.occludees + hidden.push(spanMap.isHidden(aabb.x0, aabb.y0, aabb.x1, aabb.y1, aabb.depth)) + end for + m.queryMs += timer.TotalMilliseconds() + + ' The brute-force check is slow, so it's queued (first frame's verdicts) and run within a 50ms per-frame budget. + if m.frameIndex = 0 + for i = 0 to m.occludees.count() - 1 + if hidden[i] + m.hiddenCount++ + m.pendingChecks.push(i) + end if + end for + end if + end if + ' Time-boxed so the app stays responsive; at least one check per frame so it progresses. + checkTimer = CreateObject("roTimespan") + while m.pendingChecks.count() > 0 + if not m.bruteForceHidden(m.occludees[m.pendingChecks.pop()]) + m.falseHides++ + end if + if checkTimer.TotalMilliseconds() >= m.checkBudgetMs + exit while + end if + end while + + m.frameIndex++ + if m.frameIndex >= m.framesPerConfig and m.pendingChecks.count() = 0 + builds = m.buildsPerFrame * m.framesPerConfig + queryPer = m.queryMs / (m.framesPerConfig * m.occludeeCount) + line = `k=${m.occluderCounts[m.configIndex]} bands=${m.bandCount}: build ${m.buildMs / builds} ms, query ${queryPer * 1000} us/obj, hidden ${m.hiddenCount}/${m.occludeeCount}, falseHides ${m.falseHides}` + print `[Occlusion span benchmark] ${line}` + m.results.push(line) + m.configIndex++ + if m.configIndex < m.occluderCounts.count() + m.startConfig() + else + m.buildVisual() + end if + end if + end sub + + private sub buildVisual() + m.occluders = m.makeOccluders(12, 10, 50) + ' All behind every occluder, so the picture is purely about screen coverage. + m.occludees = m.makeOccludees(60, 60, 100) + spanMap = new BGE.OcclusionSpanMap(m.width, m.height, m.bandCount) + spanMap.build(m.occluders) + m.visualHidden = [] + hiddenCount = 0 + falseHides = 0 + for each aabb in m.occludees + isHidden = spanMap.isHidden(aabb.x0, aabb.y0, aabb.x1, aabb.y1, aabb.depth) + m.visualHidden.push(isHidden) + if isHidden + hiddenCount++ + if not m.bruteForceHidden(aabb) + falseHides++ + end if + end if + end for + m.visualReady = true + m.visualLine = `visual: hidden ${hiddenCount}/${m.occludees.count()}, falseHides ${falseHides} (OK = new scene)` + print `[Occlusion span benchmark] ${m.visualLine}` + end sub + + private function makeOccluders(count as integer, minDepth as float, maxDepth as float) as BGE.OccluderPolygon[] + list = [] + for i = 1 to count + cx = Rnd(0) * m.width + cy = Rnd(0) * m.height + hw = 50 + Rnd(0) * 150 + hh = 50 + Rnd(0) * 150 + angle = Rnd(0) * 3.14159 + c = cos(angle) + s = sin(angle) + corners = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]] + if Rnd(0) < 0.3 + corners = [[-hw, hh], [0, -hh], [hw, hh]] + end if + xs = [] + ys = [] + for each p in corners + xs.push(cx + p[0] * c - p[1] * s) + ys.push(cy + p[0] * s + p[1] * c) + end for + list.push({xs: xs, ys: ys, depth: minDepth + Rnd(0) * (maxDepth - minDepth), area: hw * hh}) + end for + return list + end function + + private function makeOccludees(count as integer, minDepth as float, maxDepth as float) as object[] + list = [] + for i = 1 to count + w = 10 + Rnd(0) * 70 + h = 10 + Rnd(0) * 70 + x0 = Rnd(0) * (m.width - w) + y0 = Rnd(0) * (m.height - h) + list.push({x0: x0, y0: y0, x1: x0 + w, y1: y0 + h, depth: minDepth + Rnd(0) * (maxDepth - minDepth)}) + end for + return list + end function + + ' Samples the aabb every 2px; every sample must lie in a nearer occluder. + private function bruteForceHidden(aabb as object) as boolean + y = aabb.y0 + while true + x = aabb.x0 + while true + covered = false + for each occ in m.occluders + if occ.depth < aabb.depth and m.pointInConvex(occ.xs, occ.ys, x, y) + covered = true + exit for + end if + end for + if not covered + return false + end if + if x >= aabb.x1 + exit while + end if + x = BGE.Math.Min(x + 2, aabb.x1) + end while + if y >= aabb.y1 + exit while + end if + y = BGE.Math.Min(y + 2, aabb.y1) + end while + return true + end function + + private function pointInConvex(xs as float[], ys as float[], x as float, y as float) as boolean + n = xs.count() + sign = 0 + for i = 0 to n - 1 + j = (i + 1) mod n + cross = (xs[j] - xs[i]) * (y - ys[i]) - (ys[j] - ys[i]) * (x - xs[i]) + if cross <> 0 + s = 1 + if cross < 0 + s = -1 + end if + if sign = 0 + sign = s + else if s <> sign + return false + end if + end if + end for + return true + end function + + override sub draw(renderer as BGE.Renderer) + if m.visualReady + for each occ in m.occluders + points = [] + for i = 0 to occ.xs.count() - 1 + points.push(BGE.Math.VectorOps.create(occ.xs[i], occ.ys[i], 0)) + end for + renderer.drawPolygon(points, 0, 0, &h3060C0A0) + end for + for i = 0 to m.occludees.count() - 1 + aabb = m.occludees[i] + rgba = BGE.Colors.Green + if m.visualHidden[i] + rgba = BGE.Colors.Red + end if + renderer.drawLine(aabb.x0, aabb.y0, aabb.x1, aabb.y0, rgba) + renderer.drawLine(aabb.x1, aabb.y0, aabb.x1, aabb.y1, rgba) + renderer.drawLine(aabb.x1, aabb.y1, aabb.x0, aabb.y1, rgba) + renderer.drawLine(aabb.x0, aabb.y1, aabb.x0, aabb.y0, rgba) + end for + end if + + ' y=80 clears main.bs's title header (see QuadFillBenchmark.bs). + y = 80 + for each line in m.results + renderer.drawRectangle(130, y - 2, 1020, 24, &h000000C0) + renderer.drawText(line, 140, y, BGE.Colors.White, m.font) + y += 26 + end for + if m.visualReady + renderer.drawRectangle(130, y - 2, 1020, 24, &h000000C0) + renderer.drawText(m.visualLine, 140, y, BGE.Colors.Yellow, m.font) + else + renderer.drawText(`running config ${m.configIndex + 1}/${m.occluderCounts.count()}`, 140, y, BGE.Colors.White, m.font) + end if + end sub + +end class diff --git a/examples/terrain/src/models/chapel/chapel.mtl b/examples/terrain/src/models/chapel/chapel.mtl new file mode 100644 index 00000000..23c79ff6 --- /dev/null +++ b/examples/terrain/src/models/chapel/chapel.mtl @@ -0,0 +1,49 @@ +# Spooky low-poly chapel materials +newmtl stone +Ka 0 0 0 +Kd 0.780 0.780 0.740 +Ks 0.05 0.05 0.05 +Ns 10 +d 1 +illum 2 +map_Kd textures/stone.png +map_Bump textures/stone.png + +newmtl mossy_slate +Ka 0 0 0 +Kd 0.700 0.720 0.700 +Ks 0.05 0.05 0.05 +Ns 30 +d 1 +illum 2 +map_Kd textures/slate.png +map_Bump textures/slate.png + +newmtl nave_wall +Ka 0 0 0 +Kd 0.850 0.850 0.820 +Ks 0.05 0.05 0.05 +Ns 10 +d 1 +illum 2 +map_Kd textures/naveWall.png + +newmtl tower_face +Ka 0 0 0 +Kd 0.850 0.850 0.820 +Ks 0.05 0.05 0.05 +Ns 10 +d 1 +illum 2 +map_Kd textures/towerFace.png + +newmtl iron_cross +Ka 0 0 0 +Kd 0.850 0.850 0.850 +Ks 0.05 0.05 0.05 +Ns 80 +d 1 +illum 2 +map_Kd textures/cross.png +map_d textures/cross.png + diff --git a/examples/terrain/src/models/chapel/chapel.obj b/examples/terrain/src/models/chapel/chapel.obj new file mode 100644 index 00000000..9b26b36f --- /dev/null +++ b/examples/terrain/src/models/chapel/chapel.obj @@ -0,0 +1,297 @@ +# Spooky low-poly chapel +o nave_gables +usemtl stone +v -3.0000 0.0000 5.0000 +v 3.0000 0.0000 5.0000 +v 3.0000 4.0000 5.0000 +v -3.0000 0.0000 5.0000 +v 3.0000 4.0000 5.0000 +v 0.0000 6.5000 5.0000 +v -3.0000 0.0000 5.0000 +v 0.0000 6.5000 5.0000 +v -3.0000 4.0000 5.0000 +v -3.0000 0.0000 -5.0000 +v 3.0000 0.0000 -5.0000 +v 3.0000 4.0000 -5.0000 +v -3.0000 0.0000 -5.0000 +v 3.0000 4.0000 -5.0000 +v 0.0000 6.5000 -5.0000 +v -3.0000 0.0000 -5.0000 +v 0.0000 6.5000 -5.0000 +v -3.0000 4.0000 -5.0000 +vt -1.3636 0.0000 +vt 1.3636 0.0000 +vt 1.3636 1.8182 +vt -1.3636 0.0000 +vt 1.3636 1.8182 +vt 0.0000 2.9545 +vt -1.3636 0.0000 +vt 0.0000 2.9545 +vt -1.3636 1.8182 +vt -1.3636 0.0000 +vt 1.3636 0.0000 +vt 1.3636 1.8182 +vt -1.3636 0.0000 +vt 1.3636 1.8182 +vt 0.0000 2.9545 +vt -1.3636 0.0000 +vt 0.0000 2.9545 +vt -1.3636 1.8182 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +f 1/1/1 2/2/2 3/3/3 +f 4/4/4 5/5/5 6/6/6 +f 7/7/7 8/8/8 9/9/9 +f 10/10/10 12/12/12 11/11/11 +f 13/13/13 15/15/15 14/14/14 +f 16/16/16 18/18/18 17/17/17 +o nave_sides +usemtl nave_wall +v 3.0000 0.0000 5.0000 +v 3.0000 0.0000 -5.0000 +v 3.0000 4.0000 -5.0000 +v 3.0000 0.0000 5.0000 +v 3.0000 4.0000 -5.0000 +v 3.0000 4.0000 5.0000 +v -3.0000 0.0000 -5.0000 +v -3.0000 0.0000 5.0000 +v -3.0000 4.0000 5.0000 +v -3.0000 0.0000 -5.0000 +v -3.0000 4.0000 5.0000 +v -3.0000 4.0000 -5.0000 +vt 0.0000 0.0000 +vt 1.0000 0.0000 +vt 1.0000 1.0000 +vt 0.0000 0.0000 +vt 1.0000 1.0000 +vt 0.0000 1.0000 +vt 0.0000 0.0000 +vt 1.0000 0.0000 +vt 1.0000 1.0000 +vt 0.0000 0.0000 +vt 1.0000 1.0000 +vt 0.0000 1.0000 +vn 1.0000 0.0000 0.0000 +vn 1.0000 0.0000 0.0000 +vn 1.0000 0.0000 0.0000 +vn 1.0000 0.0000 0.0000 +vn 1.0000 0.0000 0.0000 +vn 1.0000 0.0000 0.0000 +vn -1.0000 0.0000 0.0000 +vn -1.0000 0.0000 0.0000 +vn -1.0000 0.0000 0.0000 +vn -1.0000 0.0000 0.0000 +vn -1.0000 0.0000 0.0000 +vn -1.0000 0.0000 0.0000 +f 19/19/19 20/20/20 21/21/21 +f 22/22/22 23/23/23 24/24/24 +f 25/25/25 26/26/26 27/27/27 +f 28/28/28 29/29/29 30/30/30 +o roof +usemtl mossy_slate +v -3.4000 3.6967 5.3500 +v 0.0000 6.5300 5.3500 +v 0.0000 6.5300 -5.3500 +v -3.4000 3.6967 5.3500 +v 0.0000 6.5300 -5.3500 +v -3.4000 3.6967 -5.3500 +v 0.0000 6.5300 5.3500 +v 3.4000 3.6967 5.3500 +v 3.4000 3.6967 -5.3500 +v 0.0000 6.5300 5.3500 +v 3.4000 3.6967 -5.3500 +v 0.0000 6.5300 -5.3500 +vt 3.3437 -0.1534 +vt 3.3437 2.6128 +vt -3.3437 2.6128 +vt 3.3437 -0.1534 +vt -3.3437 2.6128 +vt -3.3437 -0.1534 +vt -3.3437 2.6128 +vt -3.3437 -0.1534 +vt 3.3437 -0.1534 +vt -3.3437 2.6128 +vt 3.3437 -0.1534 +vt 3.3437 2.6128 +vn -0.6402 0.7682 0.0000 +vn -0.6402 0.7682 0.0000 +vn -0.6402 0.7682 0.0000 +vn -0.6402 0.7682 0.0000 +vn -0.6402 0.7682 0.0000 +vn -0.6402 0.7682 0.0000 +vn 0.6402 0.7682 0.0000 +vn 0.6402 0.7682 0.0000 +vn 0.6402 0.7682 0.0000 +vn 0.6402 0.7682 0.0000 +vn 0.6402 0.7682 0.0000 +vn 0.6402 0.7682 0.0000 +f 31/31/31 32/32/32 33/33/33 +f 34/34/34 35/35/35 36/36/36 +f 37/37/37 38/38/38 39/39/39 +f 40/40/40 41/41/41 42/42/42 +o tower_front +usemtl tower_face +v -1.3000 0.0000 7.6000 +v 1.3000 0.0000 7.6000 +v 1.3000 8.0000 7.6000 +v -1.3000 0.0000 7.6000 +v 1.3000 8.0000 7.6000 +v -1.3000 8.0000 7.6000 +vt 0.0000 0.0000 +vt 1.0000 0.0000 +vt 1.0000 1.0000 +vt 0.0000 0.0000 +vt 1.0000 1.0000 +vt 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +f 43/43/43 44/44/44 45/45/45 +f 46/46/46 47/47/47 48/48/48 +o tower +usemtl stone +v 1.3000 0.0000 7.6000 +v 1.3000 0.0000 4.9000 +v 1.3000 8.0000 4.9000 +v 1.3000 0.0000 7.6000 +v 1.3000 8.0000 4.9000 +v 1.3000 8.0000 7.6000 +v -1.3000 0.0000 4.9000 +v -1.3000 0.0000 7.6000 +v -1.3000 8.0000 7.6000 +v -1.3000 0.0000 4.9000 +v -1.3000 8.0000 7.6000 +v -1.3000 8.0000 4.9000 +v 1.3000 0.0000 4.9000 +v -1.3000 0.0000 4.9000 +v -1.3000 8.0000 4.9000 +v 1.3000 0.0000 4.9000 +v -1.3000 8.0000 4.9000 +v 1.3000 8.0000 4.9000 +vt -3.4545 0.0000 +vt -2.2273 0.0000 +vt -2.2273 3.6364 +vt -3.4545 0.0000 +vt -2.2273 3.6364 +vt -3.4545 3.6364 +vt 2.2273 0.0000 +vt 3.4545 0.0000 +vt 3.4545 3.6364 +vt 2.2273 0.0000 +vt 3.4545 3.6364 +vt 2.2273 3.6364 +vt -0.5909 0.0000 +vt 0.5909 0.0000 +vt 0.5909 3.6364 +vt -0.5909 0.0000 +vt 0.5909 3.6364 +vt -0.5909 3.6364 +vn 1.0000 0.0000 0.0000 +vn 1.0000 0.0000 0.0000 +vn 1.0000 0.0000 0.0000 +vn 1.0000 0.0000 0.0000 +vn 1.0000 0.0000 0.0000 +vn 1.0000 0.0000 0.0000 +vn -1.0000 0.0000 0.0000 +vn -1.0000 0.0000 0.0000 +vn -1.0000 0.0000 0.0000 +vn -1.0000 0.0000 0.0000 +vn -1.0000 0.0000 0.0000 +vn -1.0000 0.0000 0.0000 +vn 0.0000 0.0000 -1.0000 +vn 0.0000 0.0000 -1.0000 +vn 0.0000 0.0000 -1.0000 +vn 0.0000 0.0000 -1.0000 +vn 0.0000 0.0000 -1.0000 +vn 0.0000 0.0000 -1.0000 +f 49/49/49 50/50/50 51/51/51 +f 52/52/52 53/53/53 54/54/54 +f 55/55/55 56/56/56 57/57/57 +f 58/58/58 59/59/59 60/60/60 +f 61/61/61 62/62/62 63/63/63 +f 64/64/64 65/65/65 66/66/66 +o spire +usemtl mossy_slate +v -1.3000 8.0000 7.6000 +v 1.3000 8.0000 7.6000 +v 0.0000 12.0000 6.2500 +v 1.3000 8.0000 7.6000 +v 1.3000 8.0000 4.9000 +v 0.0000 12.0000 6.2500 +v 1.3000 8.0000 4.9000 +v -1.3000 8.0000 4.9000 +v 0.0000 12.0000 6.2500 +v -1.3000 8.0000 4.9000 +v -1.3000 8.0000 7.6000 +v 0.0000 12.0000 6.2500 +vt -0.8125 3.2185 +vt 0.8125 3.2185 +vt 0.0000 5.8571 +vt -4.7500 4.5040 +vt -3.0625 4.5040 +vt -3.9063 7.1328 +vt -0.8125 5.7168 +vt 0.8125 5.7168 +vt 0.0000 8.3553 +vt 3.0625 4.5040 +vt 4.7500 4.5040 +vt 3.9063 7.1328 +vn 0.0000 0.3198 0.9475 +vn 0.0000 0.3198 0.9475 +vn 0.0000 0.3198 0.9475 +vn 0.9510 0.3091 0.0000 +vn 0.9510 0.3091 0.0000 +vn 0.9510 0.3091 0.0000 +vn 0.0000 0.3198 -0.9475 +vn 0.0000 0.3198 -0.9475 +vn 0.0000 0.3198 -0.9475 +vn -0.9510 0.3091 0.0000 +vn -0.9510 0.3091 0.0000 +vn -0.9510 0.3091 0.0000 +f 67/67/67 68/68/68 69/69/69 +f 70/70/70 71/71/71 72/72/72 +f 73/73/73 74/74/74 75/75/75 +f 76/76/76 77/77/77 78/78/78 +o cross +usemtl iron_cross +v -0.4000 11.8500 6.2500 +v 0.4000 11.8500 6.2500 +v 0.4000 13.2000 6.2500 +v -0.4000 11.8500 6.2500 +v 0.4000 13.2000 6.2500 +v -0.4000 13.2000 6.2500 +vt 0.0000 0.0000 +vt 1.0000 0.0000 +vt 1.0000 1.0000 +vt 0.0000 0.0000 +vt 1.0000 1.0000 +vt 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +vn 0.0000 0.0000 1.0000 +f 79/79/79 80/80/80 81/81/81 +f 82/82/82 83/83/83 84/84/84 +f 79/79/79 81/81/81 80/80/80 +f 82/82/82 84/84/84 83/83/83 diff --git a/examples/terrain/src/models/chapel/textures/cross.png b/examples/terrain/src/models/chapel/textures/cross.png new file mode 100644 index 00000000..cc82a1dd Binary files /dev/null and b/examples/terrain/src/models/chapel/textures/cross.png differ diff --git a/examples/terrain/src/models/chapel/textures/naveWall.png b/examples/terrain/src/models/chapel/textures/naveWall.png new file mode 100644 index 00000000..77746b38 Binary files /dev/null and b/examples/terrain/src/models/chapel/textures/naveWall.png differ diff --git a/examples/terrain/src/models/chapel/textures/slate.png b/examples/terrain/src/models/chapel/textures/slate.png new file mode 100644 index 00000000..18380b1b Binary files /dev/null and b/examples/terrain/src/models/chapel/textures/slate.png differ diff --git a/examples/terrain/src/models/chapel/textures/stone.png b/examples/terrain/src/models/chapel/textures/stone.png new file mode 100644 index 00000000..3f480c85 Binary files /dev/null and b/examples/terrain/src/models/chapel/textures/stone.png differ diff --git a/examples/terrain/src/models/chapel/textures/towerFace.png b/examples/terrain/src/models/chapel/textures/towerFace.png new file mode 100644 index 00000000..6f24f08f Binary files /dev/null and b/examples/terrain/src/models/chapel/textures/towerFace.png differ diff --git a/examples/terrain/src/source/Entities/Chapel.bs b/examples/terrain/src/source/Entities/Chapel.bs new file mode 100644 index 00000000..c8c495d1 --- /dev/null +++ b/examples/terrain/src/source/Entities/Chapel.bs @@ -0,0 +1,24 @@ +' A static chapel model that hides the trees behind it, to show off occlusion culling +' (issue #108). Static so its drawable can register as an occluder. +class Chapel extends BGE.GameEntity + + sub new(game as BGE.Game) + super(game) + m.name = "Chapel" + end sub + + override sub onCreate(args as roAssociativeArray) + m.isStatic = true + m.position = BGE.Math.VectorOps.create(args.x, 0, args.z) + m.rotation = BGE.Math.VectorOps.create(0, BGE.Math.DegreesToRadians(args.rotY), 0) + model = m.game.get3dModel("chapel") + drawable = new BGE.DrawableModel(m, model, { + isOccluder: true, + drawMode: BGE.SceneObjectDrawMode.solid + }) + ' The model is ~13 units tall; 25x makes it ~330 against the 180-tall trees. + drawable.scale = BGE.Math.createScaleVector(25) + m.addDrawable("chapel", drawable) + end sub + +end class diff --git a/examples/terrain/src/source/Entities/TerrainFreeFlyCameraController.bs b/examples/terrain/src/source/Entities/TerrainFreeFlyCameraController.bs index c92f51ad..c605dd1d 100644 --- a/examples/terrain/src/source/Entities/TerrainFreeFlyCameraController.bs +++ b/examples/terrain/src/source/Entities/TerrainFreeFlyCameraController.bs @@ -17,6 +17,18 @@ class TerrainFreeFlyCameraController extends BGE.FreeFlyCameraController m.otherSceneName = otherSceneName end sub + ' Only the scene with occluders (WorldScene) turns this on; OK means something else elsewhere. + occlusionToggleEnabled = false + + function getOcclusionHintText() as string + renderer = m.scene.game.canvas.renderer + state = "off" + if renderer.occlusionCullingEnabled + state = "on (" + renderer.getOccludedCountLastFrame().ToStr() + " hidden)" + end if + return "Occlusion: " + state + " OK: toggle" + end function + function getHintText() as string return "Gamepad: L stick move/strafe, R stick look/pitch" + Chr(10) + "Remote: D-pad turn/move Roll: Replay/Options/L1/R1 Pitch: Rewind/FF" + Chr(10) + "Play: reset orientation Back: switch scene" + Chr(10) + "Hold Back 2s: toggle debug info" end function @@ -24,6 +36,11 @@ class TerrainFreeFlyCameraController extends BGE.FreeFlyCameraController override sub onInput(input as BGE.GameInput) super.onInput(input) + if m.occlusionToggleEnabled and input.press and input.isButton("OK") + renderer = m.scene.game.canvas.renderer + renderer.occlusionCullingEnabled = not renderer.occlusionCullingEnabled + end if + ' Back needs tap-vs-hold disambiguation: switching scenes can't fire on press (a ' hold-to-toggle-debug would never get 2 seconds to accumulate before the scene ' already changed), so a press just resets the hold tracking, and a release only @@ -50,6 +67,7 @@ class TerrainFreeFlyCameraController extends BGE.FreeFlyCameraController if m.backHeldMs >= 2000 and not m.debugToggledForThisHold m.debugEnabled = not m.debugEnabled m.scene.game.debugDrawEntityDetails(m.debugEnabled) + m.scene.game.debugDrawOcclusion(m.debugEnabled) m.debugToggledForThisHold = true end if end if diff --git a/examples/terrain/src/source/Scenes/WorldScene.bs b/examples/terrain/src/source/Scenes/WorldScene.bs index ff5e6ada..7319cffd 100644 --- a/examples/terrain/src/source/Scenes/WorldScene.bs +++ b/examples/terrain/src/source/Scenes/WorldScene.bs @@ -1,7 +1,12 @@ +import "../TerrainProfiler.bs" + class WorldScene extends BGE.GameScene cameraController as TerrainFreeFlyCameraController cameraHeight = 50 + ' Set before the scene is created to run the scripted profile instead of taking input. + profileMode = false + profiler as TerrainProfiler = invalid sub new(game as BGE.Game) super(game) @@ -26,6 +31,10 @@ class WorldScene extends BGE.GameScene loader.loadBitmap(bitmapName, bitmaps[bitmapName]) end if end for + + if m.game.Models["chapel"] = invalid + loader.load3dModel("chapel", "pkg:/models/chapel/chapel.obj") + end if end sub override sub onCreate(args as roAssociativeArray) @@ -66,10 +75,15 @@ class WorldScene extends BGE.GameScene ' m.addLowWalls() - turned off for now; LowWall entity/getWallPlacements() are left ' in place, just not instantiated. m.addGuards() + m.addChapel() camera = m.game.canvas.renderer.camera as BGE.Camera3d camera.position = BGE.Math.VectorOps.create(0, m.cameraHeight, 0) m.cameraController = new TerrainFreeFlyCameraController(m, "MainScene", groundPlaneDef) + m.cameraController.occlusionToggleEnabled = true + if m.profileMode + m.profiler = new TerrainProfiler(m.game) + end if end sub ' World size is set explicitly (not left at the source PNG's native pixel size) so @@ -131,6 +145,11 @@ class WorldScene extends BGE.GameScene end for end sub + ' Sits between the start position and a cluster of trees, hiding some of them. + private sub addChapel() + m.game.addEntity(new Chapel(m.game), {x: 80, z: -320, rotY: 180}) + end sub + ' Guard characters demonstrating DrawableOrientedSprite (issue #104) - see ' GuardPlacements.bs. private sub addGuards() @@ -148,11 +167,17 @@ class WorldScene extends BGE.GameScene end sub override sub onInput(input as BGE.GameInput) - m.cameraController.onInput(input) + if invalid = m.profiler + m.cameraController.onInput(input) + end if end sub override sub onUpdate(dt as float) - m.cameraController.update(dt) + if invalid <> m.profiler + m.profiler.update(dt) + else + m.cameraController.update(dt) + end if end sub ' BGE.GameScene's default onChangeScene() already clears this scene's own directly-added @@ -162,7 +187,12 @@ class WorldScene extends BGE.GameScene override sub onDrawEnd(renderObj as BGE.Renderer, uiRenderObj as BGE.Renderer) font = m.game.getFont("default") frameCenter = uiRenderObj.getCanvasCenter() - uiRenderObj.DrawText(m.cameraController.getHintText(), frameCenter.x, 140, BGE.Colors.White, font, "center") + if invalid <> m.profiler + m.profiler.afterDraw(renderObj) + uiRenderObj.DrawText(m.profiler.getStatusText(), frameCenter.x, 140, BGE.Colors.White, font, "center") + return + end if + uiRenderObj.DrawText(m.cameraController.getOcclusionHintText() + Chr(10) + m.cameraController.getHintText(), frameCenter.x, 140, BGE.Colors.White, font, "center") end sub end class diff --git a/examples/terrain/src/source/TerrainProfiler.bs b/examples/terrain/src/source/TerrainProfiler.bs new file mode 100644 index 00000000..a7f8ca88 --- /dev/null +++ b/examples/terrain/src/source/TerrainProfiler.bs @@ -0,0 +1,173 @@ +' Deep link `profile=1`: flies the camera along a fixed path (driven by frame index, not +' dt or input) once per occlusion configuration, and reports frame times for each. +class TerrainProfiler + + ' Frames before the first configuration, so loading/first-draw costs aren't measured. + settleFrames = 60 + moveFrames = 240 + holdFrames = 120 + ' Frames skipped at the start of each segment. + warmupFrames = 10 + + configs = [ + ' fastExactClaim draws the chapel the way it would draw were it not an occluder + {name: "off", occlusion: false, policy: BGE.OccluderDrawPolicy.fastExactClaim}, + {name: "exact", occlusion: true, policy: BGE.OccluderDrawPolicy.exact}, + {name: "fastShrink", occlusion: true, policy: BGE.OccluderDrawPolicy.fastShrink}, + {name: "fastExactClaim", occlusion: true, policy: BGE.OccluderDrawPolicy.fastExactClaim} + ] + + results = [] as string[] + done = false + + ' Where WorldScene.addChapel() puts it. + private chapelX = 80.0 + private chapelZ = -320.0 + private game as BGE.Game + private camera as BGE.Camera3d + private startPosition as BGE.Math.Vector + ' -1 while settling + private configIndex = -1 + ' The path frame the camera is posed for, -1 before the first. + private pathFrame = -1 + private stats as roAssociativeArray + + sub new(game as BGE.Game) + m.game = game + m.camera = game.canvas.renderer.camera as BGE.Camera3d + m.startPosition = BGE.Math.VectorOps.copy(m.camera.position) + end sub + + ' Call from the scene's onUpdate(). dt is how long the previous frame took. + sub update(dt as float) + if m.done + return + end if + if m.pathFrame >= 0 and m.configIndex >= 0 + m.addSample(m.pathFrame, "ms", dt * 1000) + end if + nextFrame = m.pathFrame + 1 + if nextFrame >= m.getFrameCount() + m.finishConfig() + m.configIndex++ + if m.configIndex >= m.configs.count() + m.done = true + m.posePath(0) + return + end if + m.startConfig() + nextFrame = 0 + end if + m.pathFrame = nextFrame + m.posePath(nextFrame) + end sub + + ' Call from the scene's onDrawEnd(), after the game canvas has rendered this frame. + sub afterDraw(renderer as BGE.Renderer) + if m.done or m.configIndex < 0 or m.pathFrame < 0 + return + end if + m.addSample(m.pathFrame, "drawCalls", renderer.getDrawCallsLastFrame()) + m.addSample(m.pathFrame, "occluded", renderer.getOccludedCountLastFrame()) + m.addSample(m.pathFrame, "occlusionMs", renderer.getOcclusionMsLastFrame()) + m.addSample(m.pathFrame, "tests", renderer.getOcclusionTestsLastFrame()) + end sub + + function getStatusText() as string + if m.done + return "Profile done (quality " + m.game.renderQuality.getLevel().ToStr() + ")" + Chr(10) + m.results.Join(Chr(10)) + end if + if m.configIndex < 0 + return "Profile: settling" + end if + return "Profile: " + m.configs[m.configIndex].name + " frame " + m.pathFrame.ToStr() + end function + + private function getFrameCount() as integer + if m.configIndex < 0 + return m.settleFrames + end if + return m.moveFrames + m.holdFrames + end function + + private sub startConfig() + config = m.configs[m.configIndex] + renderer = m.game.canvas.renderer + renderer.occlusionCullingEnabled = config.occlusion + renderer.occluderDrawPolicy = config.policy + m.stats = { + moving: {ms: [], drawCalls: [], occluded: [], occlusionMs: [], tests: []}, + still: {ms: [], drawCalls: [], occluded: [], occlusionMs: [], tests: []} + } + end sub + + private sub addSample(frame as integer, key as string, value as float) + if m.configIndex < 0 + return + end if + segment = m.stats.moving + segmentFrame = frame + if frame >= m.moveFrames + segment = m.stats.still + segmentFrame = frame - m.moveFrames + end if + if segmentFrame >= m.warmupFrames + segment[key].push(value) + end if + end sub + + private sub finishConfig() + if m.configIndex < 0 + return + end if + moving = m.stats.moving + still = m.stats.still + line = m.configs[m.configIndex].name + ": moving " + m.describeSegment(moving) + " | still " + m.describeSegment(still) + print "[terrain profile] " + line + m.results.push(line) + end sub + + ' Averages for one segment; occlusion ms and tests are the occlusion pass's own cost. + private function describeSegment(segment as roAssociativeArray) as string + text = "avg " + m.format(m.average(segment.ms)) + "ms worst " + m.format(m.maximum(segment.ms)) + "ms" + text += " occl " + m.format(m.average(segment.occlusionMs)) + "ms tests " + m.format(m.average(segment.tests)) + return text + " occluded " + m.format(m.average(segment.occluded)) + " draws " + m.format(m.average(segment.drawCalls)) + end function + + ' Strafes right, then left, then back to the middle while edging forward, turning to keep + ' the chapel in view, so the trees behind it slide in and out of its shadow. Then holds. + private sub posePath(frame as integer) + t = BGE.Math.Min(frame, m.moveFrames) / m.moveFrames + x = m.startPosition.x + 200 * Sin(2 * BGE.Math.PI * t) + z = m.startPosition.z - 60 * t + m.camera.position = BGE.Math.VectorOps.create(x, m.startPosition.y, z) + ' starts at the normal view straight ahead, and turns toward the chapel over the first tenth + towardChapel = Atn((m.chapelX - x) / (z - m.chapelZ)) + heading = towardChapel * BGE.Math.Min(1, t * 10) + m.camera.orientation = BGE.Math.VectorOps.create(Sin(heading), 0, -Cos(heading)) + end sub + + private function average(values as float[]) as float + if values.count() = 0 + return 0 + end if + total = 0.0 + for each value in values + total += value + end for + return total / values.count() + end function + + private function maximum(values as float[]) as float + result = 0.0 + for each value in values + result = BGE.Math.Max(result, value) + end for + return result + end function + + private function format(value as float) as string + return (Int(value * 10 + 0.5) / 10).ToStr() + end function + +end class diff --git a/examples/terrain/src/source/main.bs b/examples/terrain/src/source/main.bs index 6f264600..0f0177db 100644 --- a/examples/terrain/src/source/main.bs +++ b/examples/terrain/src/source/main.bs @@ -1,4 +1,4 @@ -sub main() +sub main(args as dynamic) game = new BGE.Game(1280, 720) ' This initializes the game engine game.scaleCanvasToFillScreen() game.setCamera(new BGE.Camera3d()) @@ -7,7 +7,9 @@ sub main() game.controls.bindAxis("move", "1", 0, "Move/Strafe") game.controls.bindAxis("look", "2", 0, "Look/Pitch") + profileMode = args <> invalid and args.profile <> invalid and args.profile = "1" world_Scene = new WorldScene(game) + world_Scene.profileMode = profileMode game.defineScene(world_Scene) main_Scene = new MainScene(game) @@ -17,8 +19,13 @@ sub main() game.enableStandardDebugUi() - ' Hold the default 20fps target by moving the render quality level automatically - game.enableAdaptiveQuality() + if profileMode + ' pinned at the level the device starts at, so it can't change mid-profile + game.setQualityLevel(BGE.seedRenderQualityLevelForDevice()) + else + ' Hold the default 20fps target by moving the render quality level automatically + game.enableAdaptiveQuality() + end if ' Entity-detail overlays render oddly for the Ground plane (it doesn't have ' a normal bounded screen area like other entities), so leave that off. diff --git a/specs/2026-10-16-occlusion-culling-design.md b/specs/2026-10-16-occlusion-culling-design.md new file mode 100644 index 00000000..10dda128 --- /dev/null +++ b/specs/2026-10-16-occlusion-culling-design.md @@ -0,0 +1,243 @@ +# Occlusion culling (issue #108) - design + +## Intent + +When a large, solid piece of static geometry (a building, a wall) is in the foreground, anything fully hidden behind it does not need drawing. The renderer should skip those objects. It should also skip the per-frame work that leads up to drawing them: canvas projection, temp-bitmap rasterization and the draw call itself. + +The motivating scene is `examples/terrain` with a static chapel model standing among the trees. Trees behind the chapel are some of the most expensive draws in that scene. + +## Goals + +- Static geometry the game marks as an occluder hides anything fully behind it. +- Several occluders together can hide one object, for example two walls meeting at a corner, or the faces of one model. +- Game code can ask why an entity wasn't drawn: visible, occluded, or off camera. +- No measurable cost for a renderer with no occluders registered. + +## Non-goals + +- Moving occluders. Only `isStatic` entities can occlude in this version. +- Multi-material OBJ models. That is #341; the chapel draws with one texture until then. +- Skipping `onUpdate()`, collisions or `SceneObject.update()` for hidden objects. Gameplay keeps running, and the engine still doesn't skip game logic for off-screen entities. +- Occlusion on the UI renderer. `render()` is never called on it. + +## Anti-goals (failure even if it "works") + +- **Popping.** An object that is partly visible must never be skipped, even for one frame. Every rule below is chosen so that the only failure mode is "drew something that was actually hidden", never the reverse. +- **A frame-time regression** in scenes that don't register occluders, or with `occlusionCullingEnabled = false`. + +A scene that does register occluders may lose time on moving frames when little is hidden. That's a trade-off for the game to make, and the PR has to report it honestly; it isn't a failure of the feature. The owner wants to judge this as a general engine feature, not by the terrain example alone. + +## Decisions made with the project owner + +1. **Static-only, opt-in occluders.** Being static isn't enough, because a static tree billboard must not hide anything. An occluder has to be explicitly marked. +2. **Static 3D models can be occluders**, as well as static quads. The chapel is a `Model3d`. +3. **Conservative.** Skip only when the object is provably fully hidden. +4. **Visibility enum.** It replaces the boolean culled signal, and `GameEntity.isOnScreen()` stays as a convenience wrapper. `Drawable.isOnScreen()` goes away in favour of `Drawable.getVisibility()`. +5. **Algorithm: a screen-space span occlusion map**, chosen after a device benchmark (below). A native-bitmap variant was tried and rejected as both slower and non-conservative. +6. **The band count is a fraction of the renderer's draw height**, not a fixed pixel size, so the cost doesn't depend on canvas resolution. + +## Benchmark evidence (spike, real Roku, `rendererTest`) + +The spike is the `occlusion-span-benchmark` demo on branch `spike/issue-108-occlusion-grid`, uncommitted. + +Test setup: +- Random screen-space quads and triangles as occluders, 300 random boxes as occludees. +- Every "hidden" verdict brute-force checked by sampling the box every 2px. + +| Map type | Occluders | Band | Build | Check per object | False hides | +|---|---|---|---|---|---| +| span (optimised) | 8 | 16px | 2.1-2.4 ms | 13 µs | 0 | +| span (optimised) | 32 | 16px | 12.5 ms | 17 µs | 0 | +| span (optimised) | 32 | 8px | 23.8 ms | 23 µs | 0 | +| span (optimised) | 128 | 8px | 92 ms | 20 µs | 0 | +| native bitmap | 32 | 16px | 22.4 ms (2 ms of it readback) | 20 µs | **12** | + +Findings: +- **Build cost** is about 20 µs per occluder per band it covers. +- **When it's paid:** only on frames where the camera moved or the set of occluders changed. A still frame reuses the map. +- **Break-even:** an oriented billboard or model costs on the order of 1-2 ms to draw, so the build pays for itself when it hides even a couple of them. The spike numbers leave out projecting the occluders from 3D to screen. + +## How it works + +### Marking occluders + +- `Drawable.isOccluder as boolean = false`, also accepted as an `isOccluder` key in drawable args. + - It only takes effect when `owner.isStatic` is true. + - Setting it on a non-static or ownerless drawable logs a warning once and is otherwise ignored. + - *My call:* drawable-level rather than entity-level, so a static chapel entity can leave a thin decorative drawable such as a cross out of occlusion. +- The game asserts that a flagged drawable is opaque. The engine can't tell whether an `Image`'s texture has transparent pixels, which is why this is opt-in. +- **Registration is separate from BSP eligibility.** Today `Renderer.markStatic()` declines anything that isn't an Image or Rectangle, so a static model ends up with `sceneObj.isStatic = false`. + - Add a `Renderer` occluder registration, e.g. `markOccluder(sceneObj)`, called alongside `markStatic` wherever that's called today. + - It covers `SceneObjectImage`, `SceneObjectRectangle` and `SceneObjectModel`. + - The BSP stays Image/Rectangle-only. + - A static model still isn't a BSP node; it only joins the occluder list. + +### Which occluders count each frame + +An occluder contributes this frame only if all of these hold: +- it is enabled; +- `distanceFadeAlpha = 1`; +- `Drawable.alpha = 255`; +- its draw mode isn't `wireFrame*`, and isn't a screen-aligned mode (`matchCamera`, `directToCamera`, `directScaled`); +- the camera is a `Camera3d`. + +*My call:* screen-aligned and `Camera2d` occluders are out of scope for v1. `markOccluder` rejects a screen-aligned scene object with a one-time warning, the same way the BSP's eligibility check does, and occlusion is skipped entirely under a `Camera2d`. +- a `Rectangle` is `filled`. + +What it contributes: +- **Quads:** only if all 4 corners project. A quad facing away from the camera counts only in a `*DrawBackFace` mode. +- **Models:** their faces, under the same rules - front-facing, or any face in `*DrawBackFace` modes, and all 3 vertices must project. + +**Coplanar merge for models:** +- *My call:* when a static model is first registered as an occluder, merge its coplanar, edge-sharing triangles into convex panels. +- Store them in the model's local space, so it's computed once. +- This cuts how many occluders feed the build. The span map would merge adjacent triangles anyway, but fewer, larger polygons are cheaper to build from. +- If a merged panel would be non-convex, keep the triangles separate. + +**Cap:** +- `Renderer.maxOccluders`, default 32: if more occluder polygons qualify, only the largest by projected screen area are used. +- *My call* on the default; tune it on the device. + +### The span map + +Built once per frame, after `updateSceneObjects()` and before the draw passes. It is rebuilt only when the camera moved, an occluder's enabled state, fade or geometry changed, or the canvas size changed. Otherwise last frame's map is kept. + +**Structure:** +- The canvas is split into `Renderer.occlusionBandCount` horizontal bands, default 45 (16px at 720p). +- For each band, the map holds a sorted list of merged `[left, right, farthestDepth]` x-ranges. + +**Building it:** +- For each occluder polygon, a convex shape in canvas space: + - find its top and bottom vertices; + - walk its two edge chains once, recording x at every band boundary inside its y-range. +- A band lying wholly inside the polygon's y-range is covered across `[max(left at top, left at bottom), min(right at top, right at bottom)]`. This is exact for convex shapes. +- Insert that range into the band's list, merging with any range it overlaps or touches. +- A merged range keeps the **largest** depth of the occluders in it. That is conservative: an object must be behind every occluder that was merged in. + +**Depth:** +- Use the same measure everywhere: `Camera.distanceFromCameraFront()`. +- An occluder's depth is its farthest projected vertex. + +### Testing an object + +**Candidates:** every enabled `SceneObject` except: +- planes, skyboxes and parallax layers (drawn under everything); +- registered occluders themselves (*my call:* occluders are always drawn, which keeps the rules simple); +- anything already culled as off camera this frame. + +**The test:** +1. Use the object's `getBoundingPoints()`. If **any** point fails to project, the object is not occluded. + - `DepthSort.getScreenBounds()` drops points that fail to project and so under-reports an object's size; it must not be reused as-is. +2. Take the screen AABB of the projected points, inflated by `occlusionMargin`. *My call:* 2px default, covering rounding and the temp-bitmap drift described below. +3. The object's nearest depth is the minimum over its bounding points. +4. It is hidden if, in every band its AABB touches, one merged range contains `[minX, maxX]` and that range's farthest depth is less than the object's nearest depth. +5. Off-canvas boxes are never "occluded" (they're off camera). + +**Re-testing:** an object's verdict is recomputed only when the map was rebuilt or the object itself moved, changed geometry or changed draw mode. Otherwise last frame's verdict stands. + +**Interaction with the occluder's own drawing:** +- **Approximate redraws.** While moving, an occluder may draw from a slightly stale or approximately transformed temp bitmap, with up to 16px drift for billboards and 64px for models (`attemptTransformTempBitmap`). + - *Builder to decide:* either inflate by that threshold, or exclude occluders whose last draw used an approximate transform. + - The second is preferred if it's cheap to detect, since a 64px margin would hide very little. +- **Coarse fills.** Quick-draw triangle skipping and banding only affect small or mid-size triangles, which aren't big enough to matter as occluders. The builder should confirm this on the device with the debug overlay. +- **Transient draw failures.** If an occluder's own draw fails transiently (scratch-pool or temp-bitmap allocation), the objects it hid that frame leave a one-frame hole. + - *My call:* accept this, and log it at debug level. + - Don't restructure the draw order to draw occluders first. + +### Pipeline placement + +In `Renderer.render()`: +1. `updateSceneObjects()`, as today. +2. **New:** if any occluders are registered, `updateOcclusion()` projects the occluders, (re)builds the span map if needed, and tests candidates. + - With none registered, this is one boolean check: the same zero-cost fallback as `hasStaticGeometry`. +3. Clusters, depth sort, skybox, planes: unchanged. + - Occluded objects are excluded from cluster candidacy, as culled ones are today. +4. Main pass (BSP walk or sorted loop): an occluded object is skipped with a `markSkippedOccluded()` bookkeeping call, next to the existing `markSkippedBehindCamera()` gate. + - That call records what `draw()` would have recorded: mode, geometry, projection and quality versions, `isFirstFrameSinceEnabled`. + - So an object coming back into view doesn't redraw from stale caches incorrectly, and doesn't read as changed every frame. + - It does **not** set the frustum cull latch. Occlusion has its own verdict, because the latch can't be woken by an occluder changing. + +### Visibility state + +``` +enum VisibilityState + unknown ' never drawn yet, or no scene objects / drawables + visible ' drawn last frame + occluded ' in front of the camera and in view, but fully hidden by occluders + offCamera ' outside the frustum, behind the camera, past the draw distance or faded out, + ' or a deterministic draw failure (today's cull latch) +end enum +``` + +- `SceneObject` stores its state, replacing the meaning of `lastFrameWasCulled` for reporting. + - The internal latch can stay as it is. + - `isCulled()` becomes `getVisibility() = offCamera`, or is removed if nothing else needs it; the builder decides. +- `Drawable.getVisibility()` and `GameEntity.getVisibility()` report the most visible state among their parts, in the order visible > occluded > offCamera > unknown. An empty drawable or entity reports `unknown`. +- `GameEntity.isOnScreen()` keeps its current meaning and doc: true for `visible` or `unknown`. +- `Drawable.isOnScreen()` is removed. +- Update the specs that cover these (`Drawable.spec.bs`, `GameEntity.spec.bs`) and `getClusterCandidates()`. +- *My call:* "off camera" and "past draw distance" share one state. + +### Settings + +| Field | Default | Notes | +|---|---|---| +| `Renderer.occlusionCullingEnabled` | `true` | Free when nothing is registered. Lets a game or demo A/B it. | +| `Renderer.occlusionBandCount` | 45 | Bands across the renderer's height. | +| `Renderer.maxOccluders` | 32 | Largest-by-screen-area occluder polygons per frame. | +| `Renderer.occlusionMargin` | 2 | Pixels added around each tested box. | + +None of these change what the player sees, so they are **not** `RenderQualitySettings` fields. They trade how much gets hidden against build time. + +### Debug + +A game debug value, e.g. `draw_occlusion`, that: +- outlines every occluded object's screen AABB in red; +- draws the merged span ranges translucently. + +This is how false hides get caught on the device. + +## Prerequisites + +- **#342** - an oriented `SceneObjectModel` or `SceneObjectPolygon` measures its depth from the world origin. Fix it first; occluder and occludee depths depend on it. + - Per the project owner, it's fixed as part of this work, as the first task on the same branch, with its own specs. The PR closes both #108 and #342. + +## Proving it + +- **Rooibos specs** for the span map as pure maths, using screen-space polygons in and verdicts out: + - an object fully inside one occluder; + - an object covered only by the union of two touching occluders; + - a hairline gap between two occluders, which must not hide; + - an object in front of the occluder; + - an object straddling a band boundary; + - an object partly off canvas; + - any bounding point unprojectable. +- **Specs for the visibility roll-up**, including `GameEntity.isOnScreen()` compatibility. +- **Integration specs with a real `Game`:** + - a static occluder rectangle hides a rectangle behind it; + - it doesn't hide one beside it; + - it stops hiding once disabled. +- **`examples/terrain`:** + - Add the chapel. Its source files are `/Users/mpearce/Downloads/bge aaa/export/chapel.obj`, `chapel.mtl` and `textures/` (28 triangles, 7 parts). Copy them into `examples/terrain/src/models/`; no credit is needed. Place it as a static entity flagged `isOccluder`, near enough to the trees that several are hidden from the start position. + - Scale it up to the scene: the OBJ is about 13 units tall and the trees are 180, so roughly 25x. + - Use one texture via `options.texturePath`; `stone.png` is fine until #341. + - Load it in `WorldScene.onLoad()`. +- **`rendererTest`:** keep a cleaned-up span-map benchmark as a permanent demo. *My call;* drop the native variant. +- **On the device**, record these numbers in the PR: + - **No-occluder baseline:** an existing 3D example (e.g. `examples/3d`) before and after the change. Must not regress. + - **Occlusion-heavy scene:** a new `rendererTest` demo built on `Renderer` alone, with a few large static occluder walls and many expensive oriented objects behind and beside them. Measure with `occlusionCullingEnabled` on and off, camera moving and still. This is where the feature should clearly win. + - **Terrain with the chapel:** on and off, camera moving and still. Report whatever it shows; a loss on moving frames here doesn't block the feature. +- *My call:* no adaptive auto-off in v1. `occlusionCullingEnabled` is the game's switch. If the numbers show games will need an automatic one, file it as a follow-up issue. + +## Docs + +- `CLAUDE.md` renderer bullets: pipeline placement, the zero-cost fallback, conservative rules and the enum. +- `docs/drawables-and-scene-objects.md`: a consumer-facing section on `isOccluder` and `getVisibility()`, with a short code example. + +## Left to the builder + +- The final default values, tuned on the device. +- Whether `isCulled()` survives. +- How to handle approximate-transform occluders (see above). +- Exactly where the chapel sits in terrain. +- File layout. The span map is pure maths, so it should live in its own file under `renderer/`, with a spec next to it. diff --git a/specs/2026-10-16-occlusion-culling-plan.md b/specs/2026-10-16-occlusion-culling-plan.md new file mode 100644 index 00000000..f2ec667b --- /dev/null +++ b/specs/2026-10-16-occlusion-culling-plan.md @@ -0,0 +1,461 @@ +# Occlusion Culling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Static geometry that a game flags as an occluder hides any object fully behind it, so the renderer skips projecting and drawing that object. Game code can see why an entity wasn't drawn through a `VisibilityState` enum. + +**Architecture:** +- Each frame, after `updateSceneObjects()`, the renderer does three things: + - projects registered occluders to convex canvas-space polygons; + - builds a per-band "span map" of the x-ranges they fully cover, merged across occluders; + - tests every other object's projected bounding box against it. +- Hidden objects skip `draw()` through a bookkeeping call, the same pattern as `markSkippedBehindCamera()`. +- With no occluders registered, all of this costs one boolean check. + +**Tech Stack:** BrighterScript (bsc 1.0 alpha), Rooibos v6 specs run headlessly via `brs-cli`, Roku device testing via rokubot. + +**Spec:** `specs/2026-10-16-occlusion-culling-design.md` (approved). Read it before starting any task. + +## Global Constraints + +- Only skip an object that is provably fully hidden. Every rule must fail toward drawing (anti-goal: popping). +- Only static entities' drawables, explicitly flagged `isOccluder`, occlude. Ownerless scene objects opt in with `Renderer.markOccluder()`. +- Occluders: `SceneObjectImage`, `SceneObjectRectangle` and `SceneObjectModel`, in oriented modes only (`oriented`, `orientedDrawBackFace`, `solid`, `solidDrawBackFace`), and only under a `Camera3d`. +- No cost with zero occluders, or with `Renderer.occlusionCullingEnabled = false`. +- Defaults: + - `occlusionBandCount = 45` + - `maxOccluders = 32` + - `occlusionMargin = 2` + - `occlusionCullingEnabled = true` + - These are not `RenderQualitySettings` fields. +- `VisibilityState` values: `unknown`, `visible`, `occluded`, `offCamera`. Roll-up order: visible > occluded > offCamera > unknown. +- `GameEntity.isOnScreen()` = visibility is `visible` or `unknown`. `Drawable.isOnScreen()` is removed. +- Repo rules (`CLAUDE.md`): + - one `@suite` per `*.spec.bs`; + - `assertEqual` is type-strict (int vs float); + - never `=`-compare objects (compare `id`); + - `import` every file whose symbols you use; + - no single-line `if`; + - strong types (interfaces over bare AAs); + - consumer-facing doc comments, terse inline comments; + - don't name a suite field `scene`; + - put a free function returning/taking its own file's class in a separate file. +- After every engine change run `npm run check` (lint + validate + headless tests). The task's commit only happens when that is green. + +## Review Focus + +1. **Object straddling the near plane.** An occludee with any bounding point behind the camera, or not projectable, must stay visible. Covered: Task 7 `@it("never occludes an object with a bounding point behind the camera")`. +2. **Hairline gap between two adjacent occluders.** Two quads that nearly touch must not hide an object behind the gap. Covered: Task 2 `@it("does not hide across a gap between two occluders")`. +3. **Occluder disabled, faded or removed while an object is hidden behind it, with nothing else moving.** The object must reappear that same frame, with valid canvas geometry. Covered: Task 7 `@it("shows a hidden object again the frame its occluder is disabled")` and `@it("...after its occluder is removed")`. +4. **Object in front of an occluder but overlapping it on screen.** It must stay visible. Covered: Task 2 `@it("does not hide an object nearer than the occluder")` and Task 7 `@it("does not occlude an object in front of the wall")`. +5. **An occluder drawn approximately while the camera moves** (temp-bitmap stretch or stale per-face raster). Its drawn pixels must match the geometry the map was built from. Covered: Task 6 `@it("never reuses a transformed temp bitmap for a registered occluder")`. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `src/source/engine/renderer/sceneObjects/SceneObjectModel.bs`, `SceneObjectPolygon.bs` (modify) | #342 depth fix | +| `src/source/engine/renderer/occlusion/OcclusionTypes.bs` (create) | `VisibilityState` enum, `OccluderPolygon` and `OccluderPanel` interfaces | +| `src/source/engine/renderer/occlusion/OcclusionSpanMap.bs` (create) + `.spec.bs` | Pure-maths span map | +| `src/source/engine/renderer/occlusion/OccluderPanels.bs` (create) + `.spec.bs` | Coplanar merge of model triangles into convex panels (namespace `BGE.Occlusion`) | +| `SceneObject.bs`, `SceneObjectBillboard.bs`, `SceneObjectRectangle.bs`, `SceneObjectImage.bs`, `SceneObjectModel.bs` (modify) | Visibility state, occluder polygons, skip bookkeeping | +| `src/source/engine/drawables/Drawable.bs`, `src/source/engine/GameEntity.bs` (modify) | `isOccluder`, `getVisibility()`, `isOnScreen()` wrapper | +| `src/source/engine/renderer/Renderer.bs` (modify) | Registration, `updateOcclusion()`, draw-loop gating, settings, debug overlay | +| `src/source/engine/renderer/RendererOcclusion.spec.bs` (create) | Integration specs with a real `Game` + `Camera3d` | +| `src/source/engine/Game.bs` (modify) | `debugDrawOcclusion()` | +| `examples/rendererTest/...` | Span-map benchmark (replaces the spike) + occlusion scene benchmark | +| `examples/terrain/...` | Chapel occluder + on/off toggle | +| `CLAUDE.md`, `docs/drawables-and-scene-objects.md` | Docs | + +--- + +### Task 1: Fix #342 - oriented Model/Polygon depth measured from the world origin + +**Files:** +- Modify: `src/source/engine/renderer/sceneObjects/SceneObjectModel.bs:330-335` +- Modify: `src/source/engine/renderer/sceneObjects/SceneObjectPolygon.bs:88-93` +- Test: `src/source/engine/renderer/sceneObjects/SceneObjectModel.spec.bs`, `SceneObjectPolygon.spec.bs` + +**Interfaces:** +- Produces: `getPositionForCameraDistance(oriented)` returns the object's own centre. For a model that is the centre of `modelWorldBounds`; for a polygon, the centre of the AABB of `polygonWorldPoints`. + +- [ ] **Step 1: Write the failing tests.** In each spec, add `@it("measures oriented depth from the object's own centre, not the world origin")`: + - Build a real `Game` with a `Camera3d` at (0, 0, -500) looking at the origin. + - Make an entity at position (0, 0, 300) with a cube model (or a 4-point polygon), `drawMode: oriented`. + - `renderer.setupCameraForFrame()`, then `renderer.render()`. + - Assert `sceneObj.getDepthPosition().z` is within 1 of `300` (`m.assertTrue(abs(... - 300) < 1)`). +- [ ] **Step 2:** `npm run test:ci`. Expected: both new tests FAIL, with z ≈ 0. +- [ ] **Step 3:** Implement both overrides. + - Model: `BGE.Math.VectorOps.create((min.x+max.x)/2, ...)` from `modelWorldBounds`. Confirm the field shape (`[min, max]` vectors) in `updateWorldPosition()` at line 290. + - Polygon: min/max over `polygonWorldPoints`. + - Fall back to `m.worldPosition` when the bounds or points are empty. +- [ ] **Step 4:** `npm run check`. Expected: all green. +- [ ] **Step 5:** Commit: `git commit -m "Measure oriented model/polygon depth from the object, not the origin (#342)"` + +--- + +### Task 2: Span occlusion map (pure maths) + +**Files:** +- Create: `src/source/engine/renderer/occlusion/OcclusionTypes.bs` +- Create: `src/source/engine/renderer/occlusion/OcclusionSpanMap.bs` +- Test: `src/source/engine/renderer/occlusion/OcclusionSpanMap.spec.bs` + +**Interfaces:** +- Produces, in `OcclusionTypes.bs`, namespace `BGE`: + - `enum VisibilityState` with string values `unknown`, `visible`, `occluded`, `offCamera`. + - `interface OccluderPolygon extends roAssociativeArray`: + - `xs as float[]`, `ys as float[]` - canvas-space convex polygon, either winding, 3+ points; + - `depth as float` - the farthest vertex's `distanceFromCameraFront`; + - `area as float` - projected area, used for the `maxOccluders` cap. + - `interface OccluderPanel extends roAssociativeArray`: + - `vertices as BGE.Math.Vector[]` - model-local, convex, ordered around the perimeter; + - `normal as BGE.Math.Vector` - model-local. +- Produces, in `OcclusionSpanMap.bs`, `class OcclusionSpanMap`: + - `sub new(width as float, height as float, bandCount as integer)` + - `sub build(polygons as BGE.OccluderPolygon[])` + - `function isHidden(minX as float, minY as float, maxX as float, maxY as float, nearestDepth as float) as boolean` - the caller has already applied the margin; + - `function getBandHeight() as float`, `function getBandCount() as integer`; + - `function getBandSpans(band as integer) as float[]` - flat `[left, right, depth, ...]`, for the debug overlay. + +- [ ] **Step 1: Write the failing tests** (`@suite("BGE.OcclusionSpanMap")`). All of them use a 1280x720 map with 45 bands (band height 16.0), unless noted: + - `@it("hides a box fully inside one nearer quad")` - quad (100,100)-(600,100)-(600,500)-(100,500) at depth 10; box (200,200)-(300,300) with nearest depth 20 → `assertTrue`. + - `@it("does not hide an object nearer than the occluder")` - same quad and box, nearest depth 5 → `assertFalse`. + - `@it("hides a box covered only by the union of two touching quads")` - quads x 100-400 and x 400-700, y 100-500, depths 10 and 12; box (350,200)-(450,300) at depth 20 → true. A box at depth 11 → false (a merged range keeps the farthest depth). + - `@it("does not hide across a gap between two occluders")` - quads x 100-399 and x 401-700; box (350,200)-(450,300) → false. + - `@it("does not hide a box poking out of the occluder")` - box (550,200)-(650,300) → false. + - `@it("only counts bands lying wholly inside a triangle")`: + - triangle (100,100),(700,100),(400,600) at depth 10; + - box near the apex, (390,560)-(410,590), whose band isn't fully covered → false; + - box (380,150)-(420,200) → true. + - `@it("treats a box spanning several bands as hidden only if every band covers it")` - box (200,90)-(300,300) whose top band starts above the quad → false. + - `@it("never hides a box that extends off the canvas")` - box (-10,200)-(50,300) under a quad covering the whole canvas → false. + - `@it("hides nothing when built with no polygons")`. + - `@it("rebuilding replaces the previous frame's spans")` - build once with the quad, then build with `[]` → false. +- [ ] **Step 2:** `npm run test:ci`. Expected: FAIL (class not defined). +- [ ] **Step 3:** Port the algorithm from the spike: `examples/rendererTest/src/source/Tests/OcclusionSpanBenchmark.bs`, the class `OcclusionSpanMap` and its `chainXs`/`insertSpan` helpers. + - The band count comes from the constructor; band height = `height / bandCount`. + - Remove the margin handling from `isHidden` (the caller applies it). + - Keep the spike's rules: + - a band contributes only if it lies wholly within the polygon's y range; + - the covered range is `[max of left x at the band's top and bottom edges, min of right x]`; + - merged ranges keep the max depth; + - ranges that touch (`next.left <= cur.right`) merge. + - Reuse the per-band arrays between builds (`Clear()`). +- [ ] **Step 4:** `npm run check`. Expected: green. +- [ ] **Step 5:** Commit: `git commit -m "Add screen-space span occlusion map (#108)"` + +--- + +### Task 3: Coplanar panels for static model occluders + +**Files:** +- Create: `src/source/engine/renderer/occlusion/OccluderPanels.bs` +- Test: `src/source/engine/renderer/occlusion/OccluderPanels.spec.bs` + +**Interfaces:** +- Consumes: `BGE.OccluderPanel` (Task 2), `BGE.Model3dFace` (`drawables/Model3d.bs`). +- Produces: `function BGE.Occlusion.buildModelPanels(faces as BGE.Model3dFace[]) as BGE.OccluderPanel[]` in namespace `BGE.Occlusion`. + +- [ ] **Step 1: Write the failing tests** (`@suite("BGE.Occlusion.buildModelPanels")`): + - `@it("merges two coplanar triangles sharing an edge into one 4-vertex panel")`. + - `@it("keeps triangles on different planes separate")` - two triangles at 90° → 2 panels. + - `@it("merges a cube's 12 triangles into 6 panels")`. + - `@it("does not merge when the result would be non-convex")` - three coplanar triangles forming an L shape → more than 1 panel, every panel convex. Check convexity with a helper that tests the cross-product sign along the panel. + - `@it("ignores degenerate faces")` - zero-area triangle → no panel. +- [ ] **Step 2:** `npm run test:ci`. Expected: FAIL. +- [ ] **Step 3:** Implement it with greedy merging: + - group faces by plane (normal dot within 0.999, and the plane distance within 0.01 × model bounding size); + - within a group, repeatedly merge a panel with a triangle sharing an edge (matching vertex positions within epsilon) when the merged polygon stays convex; + - drop collinear vertices. + - Model sizes are tiny (the chapel has 28 faces), so O(n²) is fine. It runs once per registration. +- [ ] **Step 4:** `npm run check`. Expected: green. +- [ ] **Step 5:** Commit: `git commit -m "Merge coplanar model faces into occluder panels (#108)"` + +--- + +### Task 4: Visibility state on SceneObject, Drawable and GameEntity + +**Files:** +- Modify: `src/source/engine/renderer/sceneObjects/SceneObject.bs` - fields near line 190, `draw()` at 422-465, `markSkippedBehindCamera()` at 392 +- Modify: `src/source/engine/drawables/Drawable.bs:375-390` +- Modify: `src/source/engine/GameEntity.bs:125-155` +- Modify: `src/source/engine/renderer/Renderer.bs:505-513` (`getClusterCandidates`) +- Test: `src/source/engine/drawables/Drawable.spec.bs`, `src/source/engine/GameEntity.spec.bs`, `src/source/engine/renderer/sceneObjects/SceneObject.spec.bs` (or the nearest existing SceneObject spec) + +**Interfaces:** +- Consumes: `BGE.VisibilityState` (Task 2's `OcclusionTypes.bs`; import it). +- Produces: + - `SceneObject.getVisibility() as BGE.VisibilityState` - initially `unknown`. + - `SceneObject.markSkippedOccluded(cameraObj as Camera)` - used by Task 7. + - `Drawable.getVisibility() as BGE.VisibilityState`. + - `GameEntity.getVisibility() as BGE.VisibilityState`. + - `GameEntity.isOnScreen() as boolean` - unchanged semantics. + - Removes `Drawable.isOnScreen()`. + - `SceneObject.isCulled()` stays, and still means "latched off camera". + +- [ ] **Step 1: Write the failing tests:** + - `SceneObject`: `@it("is unknown before its first draw")`; `@it("is visible after drawing")`; `@it("is offCamera when frustum-culled")`; `@it("is offCamera when skipped behind the camera")`; `@it("is occluded after markSkippedOccluded and forces a canvas recompute on its next draw")`. For the last one, after `markSkippedOccluded`, assert `hasValidCanvasPosition` is false through a test accessor, or spy `findCanvasPosition` on the next `draw()`. + - `Drawable`: `@it("reports the most visible state among its scene objects")` - one occluded and one offCamera gives occluded; add a visible one and it gives visible. `@it("is unknown with no scene objects")`. + - `GameEntity`: `@it("is unknown with no drawables")`; `@it("isOnScreen is true for visible and unknown, false for occluded and offCamera")`. + - Replace the existing `Drawable.isOnScreen` specs (`Drawable.spec.bs:161-187`) with `getVisibility` equivalents. +- [ ] **Step 2:** `npm run test:ci`. Expected: FAIL. +- [ ] **Step 3:** Implement: + - Add `protected visibility as BGE.VisibilityState = BGE.VisibilityState.unknown`. + - In `draw()`: after setting `lastFrameWasCulled`, set `offCamera` if `lastFrameWasCulled`, else `visible`. A transient failure reports `visible`, matching today's `isOnScreen()`. + - `markSkippedBehindCamera()` sets `offCamera`. + - `markSkippedOccluded(cameraObj)`: + - records `lastDrawMode = getActualDrawMode(cameraObj)` and `lastGeometryVersion`; + - sets `isFirstFrameSinceEnabled = false`, `lastFrameDidDraw = false`, `hasValidCanvasPosition = false`, `visibility = occluded`; + - leaves `lastFrameWasCulled = false`; + - does **not** record projection/quality versions, so a change that happens while hidden is applied on return. + - `markSkippedWhileDisabled()` leaves visibility unchanged (today's behaviour). + - Drawable and GameEntity roll-ups use the ranking order from Global Constraints. + - `getClusterCandidates()` also excludes `occluded`. +- [ ] **Step 4:** `npm run check`. Expected: green. +- [ ] **Step 5:** Commit: `git commit -m "Report visibility as an enum; keep GameEntity.isOnScreen() as a wrapper (#108)"` + +--- + +### Task 5: Occluder registration and settings + +**Files:** +- Modify: `src/source/engine/drawables/Drawable.bs` - add `isOccluder as boolean = false` near the other public fields +- Modify: `src/source/engine/renderer/sceneObjects/SceneObject.bs`, `SceneObjectImage.bs`, `SceneObjectRectangle.bs`, `SceneObjectModel.bs` +- Modify: `src/source/engine/renderer/Renderer.bs` - `addSceneObject` (428-438), `removeSceneObject` (474-489), new fields and methods +- Test: `src/source/engine/renderer/RendererOcclusion.spec.bs` (create; `@suite("BGE.Renderer - occlusion")`, setup like `RendererStaticGeometry.spec.bs`) + +**Interfaces:** +- Produces on `SceneObject`: + - `function isEligibleOccluderType() as boolean` - base `false`; Image, Rectangle and Model return `true`; + - `isOccluder as boolean` - set only by the renderer, like `isStatic`. +- Produces on `Renderer`: + - `sub markOccluder(sceneObj as SceneObject)`; + - `function hasOccluders() as boolean`; + - public fields `occlusionCullingEnabled as boolean = true`, `occlusionBandCount as integer = 45`, `maxOccluders as integer = 32`, `occlusionMargin as float = 2`; + - private `occluders as roAssociativeArray` (by scene object `id`), `occludersChanged as boolean`. + +- [ ] **Step 1: Write the failing tests:** + - `@it("registers a flagged drawable of a static entity as an occluder")` - `GameEntity(m.game, {name: "wall", isStatic: true})`, `addRectangle("r", 100, 100, {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace})` → `sceneObj.isOccluder` true and `renderer.hasOccluders()` true. + - `@it("ignores isOccluder on a non-static entity")` → false / false. + - `@it("does not register an unflagged static drawable")`. + - `@it("registers a static model")` - a `DrawableModel` with a cube model. + - `@it("declines a circle")` - an ineligible type → false. + - `@it("declines a screen-aligned draw mode")` - `directScaled` → false. + - `@it("markOccluder works on an ownerless scene object")` - same construction as `RendererStaticGeometry.spec.bs`'s ownerless test. + - `@it("unregisters on removal")` - remove the drawable → `hasOccluders()` false. +- [ ] **Step 2:** `npm run test:ci`. Expected: FAIL. +- [ ] **Step 3:** Implement: + - `addSceneObject`: if `drawable.isOccluder`, then `markOccluder` when the owner is static; otherwise log a warning once through `drawable.log(..., BGE.Debug.LogLevel.warning)`. + - `markOccluder` declines, with the same style of warning as `markStatic`, when `not isEligibleOccluderType()` or the drawable's `drawMode` is screen-aligned (`isScreenAlignedDrawMode`). + - For a model, `markOccluder` builds and caches its panels with `BGE.Occlusion.buildModelPanels(drawable.model.faces)` on the `SceneObjectModel` (`occluderPanels as BGE.OccluderPanel[]`). + - Registration and removal set `occludersChanged = true`. +- [ ] **Step 4:** `npm run check`. Expected: green. +- [ ] **Step 5:** Commit: `git commit -m "Register static drawables flagged isOccluder (#108)"` + +--- + +### Task 6: Occluder polygons per frame, drawn exactly + +**Files:** +- Modify: `SceneObject.bs`, `SceneObjectBillboard.bs` (polygon helper; `attemptTransformTempBitmap` at 935), `SceneObjectRectangle.bs`, `SceneObjectModel.bs` (per-face reuse at ~193) +- Test: `src/source/engine/renderer/RendererOcclusion.spec.bs` + +**Interfaces:** +- Consumes: `BGE.OccluderPolygon`, `BGE.OccluderPanel`, `isOccluder`. +- Produces: `function SceneObject.appendOccluderPolygons(rendererObj as Renderer, out as BGE.OccluderPolygon[]) as void` - base: no-op. Appends zero or more polygons for this frame. + +- [ ] **Step 1: Write the failing tests.** Each uses a wall rectangle 200x200 at z=0, `orientedDrawBackFace`, filled, and a `Camera3d` at (0,0,-500) looking at the origin. Call `updateSceneObjects()` through `render()` first. + - `@it("contributes one polygon for a solid wall facing the camera")` - 1 polygon, 4 points, depth > 0. + - `@it("contributes nothing in a wireframe mode")`. + - `@it("contributes nothing when partly faded")` - set `distanceFadeAlpha` through a far camera, or a test hook. + - `@it("contributes nothing when drawable alpha is below 255")`. + - `@it("contributes nothing for an unfilled rectangle")`. + - `@it("contributes nothing for a plain oriented quad facing away")`. + - `@it("contributes nothing when a corner is behind the camera")` - camera between the corners. + - `@it("contributes nothing while disabled")`. + - `@it("contributes a cube model's front-facing panels only")` - camera looking straight at one face → 1 polygon; at a corner → 3. + - `@it("never reuses a transformed temp bitmap for a registered occluder")` - move the camera slightly between two renders and spy `attemptTransformTempBitmap` (or assert its result is unused). The model per-face reuse path must skip reuse for occluders too. +- [ ] **Step 2:** `npm run test:ci`. Expected: FAIL. +- [ ] **Step 3:** Implement: + - **Shared gate:** + - `enabled`, `distanceFadeAlpha = 1`, `drawable.alpha = 255`; + - the mode is not `wireFrame*` and not screen-aligned; + - `rendererObj.camera` is a `Camera3d` (check the camera's `name = "Camera3d"`, the same check `SceneObjectSkybox` uses). + - **Billboard:** + - project its 4 world corners with `camera.worldPointToCanvasPoint`; skip it if any is `invalid`; + - backface: in a non-`*DrawBackFace` mode, skip when `BGE.Math.CornerPoints` reports the canvas quad mirrored, matching how the draw path culls; + - depth = the max `distanceFromCameraFront` over its corners; + - area = shoelace area. + - **Rectangle:** also requires `filled`. + - **Model:** + - transform each cached panel's vertices by `m.transformationMatrix` (`BGE.Math.Matrix44.multVecMatrix`) and project them; + - skip a panel if any vertex fails to project, or if it's back-facing in a non-`*DrawBackFace` mode (canvas winding); + - one polygon per panel. + - **Exact drawing:** when `m.isOccluder`, `attemptTransformTempBitmap` returns its "no reuse" result, and `SceneObjectModel`'s per-face stale reuse is skipped. That keeps drawn pixels matching this frame's geometry. +- [ ] **Step 4:** `npm run check`. Expected: green. +- [ ] **Step 5:** Commit: `git commit -m "Project occluders to canvas polygons and draw them exactly (#108)"` + +--- + +### Task 7: `Renderer.updateOcclusion()` and draw-loop gating + +**Files:** +- Modify: `src/source/engine/renderer/Renderer.bs` - `render()` (701-815), BSP loop (626-636), sorted loop (787-795) +- Modify: `SceneObject.bs` - occlusion verdict fields +- Test: `src/source/engine/renderer/RendererOcclusion.spec.bs` + +**Interfaces:** +- Consumes: `OcclusionSpanMap`, `appendOccluderPolygons`, `markSkippedOccluded`, the settings from Task 5. +- Produces: + - `private sub Renderer.updateOcclusion()`; + - `SceneObject.occludedThisFrame as boolean` (read-only by convention), plus `occlusionMapVersion` bookkeeping; + - `function Renderer.getOcclusionMap() as OcclusionSpanMap` (for the debug overlay and tests; `invalid` until first built); + - `function Renderer.getOccludedCountLastFrame() as integer`. + +- [ ] **Step 1: Write the failing integration tests.** Use a real `Game`, a `Camera3d` at (0,0,-500) looking at the origin, a static occluder wall 300x300 at z=0, and a small 20x20 rectangle "tree" (`orientedDrawBackFace`) at z=200. Render two frames per test. + - `@it("occludes an object fully behind a wall")` - tree visibility `occluded`, and its `performDraw` isn't called (spy, or compare `getDrawCallsLastFrame()` with the tree disabled). + - `@it("does not occlude an object beside the wall")` - tree at x=400 → `visible`. + - `@it("does not occlude an object in front of the wall")` - tree at z=-100 → `visible`. + - `@it("hides an object behind two touching walls")` - two 150-wide walls side by side, tree at the seam → `occluded`. + - `@it("never occludes an object with a bounding point behind the camera")` - a long tree rectangle reaching behind the camera → not `occluded`. + - `@it("shows a hidden object again the frame its occluder is disabled")` - occluded, then `wall.enabled = false` and render → `visible`, drawn. + - `@it("...after its occluder is removed")` - same, but removing the drawable. + - `@it("does nothing when occlusionCullingEnabled is false")` → `visible`. + - `@it("does nothing under a Camera2d")`. + - `@it("does no occlusion work with no occluders registered")` - `getOcclusionMap()` stays `invalid`. + - `@it("reuses the map while nothing moves")` - capture a map build counter, render twice with nothing moving → unchanged; move the camera → +1. + - `@it("keeps only the largest occluders when over maxOccluders")` - `maxOccluders = 1`, one large wall plus a small one in front of the tree; the tree is only behind the small one → `visible`. + - `@it("hides objects behind a static model occluder")` - a cube model scaled up as the wall. +- [ ] **Step 2:** `npm run test:ci`. Expected: FAIL. +- [ ] **Step 3:** Implement `updateOcclusion()`, called in `render()` right after `updateSceneObjects()`. When `not occlusionCullingEnabled or not hasOccluders() or the camera isn't a Camera3d`, return early, clearing any `occludedThisFrame` flags left from a previous frame. + - **Rebuild when** `camera.movedLastFrame()`, `occludersChanged`, the canvas size changed, or any occluder this frame: + - moved, + - changed geometry (`geometryChanged()`), + - flipped enabled (track `wasEnabledLastFrame`), + - changed `distanceFadeAlpha` from 1 or back. + - **Build:** + - collect polygons from every registered occluder; + - if there are more than `maxOccluders`, `SortBy("area", "r")` and keep the first `maxOccluders`; + - `spanMap.build(...)`; + - bump `occlusionMapVersion`; clear `occludersChanged`. + - **Test candidates:** every enabled scene object that: + - isn't an occluder; + - isn't a Plane, Skybox or ParallaxLayer (check `type`); + - is in front of the camera (`negDistanceFromCamera < 0`). + - **Re-test a candidate only if** the map version changed, or the object moved, changed geometry or changed draw mode this frame (the same signals as `depthChangedThisFrame`/`geometryChanged()`). Otherwise keep its verdict. + - **The test:** + - project every point of `getBoundingPoints(camera)`; any `invalid` means not occluded; + - inflate the AABB by `occlusionMargin`; + - nearest = min `distanceFromCameraFront`; + - `occludedThisFrame = spanMap.isHidden(...)`. + - **Draw-loop gating:** in both the BSP loop and the plain sorted loop, an object with `occludedThisFrame` calls `markSkippedOccluded(m.camera)` instead of `draw(m)`. Cluster candidacy already excludes it (Task 4), but `getClusterCandidates()` runs before this - make it check `occludedThisFrame` instead of last frame's visibility. + - **Occlusion and the frustum:** an occluded object never reached its frustum check that frame, so it can't latch off camera. If it leaves the view while hidden, the next `draw()` after it's revealed does the frustum check normally. +- [ ] **Step 4:** `npm run check`. Expected: green. +- [ ] **Step 5:** Commit: `git commit -m "Cull objects hidden behind static occluders (#108)"` + +--- + +### Task 8: Debug overlay + +**Files:** +- Modify: `src/source/engine/renderer/Renderer.bs` - draw at the end of `render()`, before `Finish()` +- Modify: `src/source/engine/Game.bs:1723` area - add `debugDrawOcclusion(enabled as boolean)` next to `debugDrawColliders` +- Test: `RendererOcclusion.spec.bs` + +**Interfaces:** +- Produces: `Renderer.showOcclusionDebug as boolean = false`; `Game.debugDrawOcclusion(enabled as boolean)` sets it on `m.canvas.renderer`. + +- [ ] **Step 1: Write the failing test** `@it("draws the occlusion overlay only when enabled")` - compare `getDrawCallsLastFrame()` between `showOcclusionDebug` false and true with one occluded object. The overlay adds draw calls. +- [ ] **Step 2:** `npm run test:ci`. Expected: FAIL. +- [ ] **Step 3:** Implement: + - each band's merged spans as translucent rectangles (`&h3060C080`); + - each occluded object's inflated AABB as a red outline (`BGE.Colors.Red`), with 4 `drawLine` calls, from the AABB stored during the test. +- [ ] **Step 4:** `npm run check`. Expected: green. +- [ ] **Step 5:** Commit: `git commit -m "Add occlusion debug overlay (#108)"` + +--- + +### Task 9: rendererTest benchmarks + +**Files:** +- Delete the spike: `examples/rendererTest/src/source/Tests/OcclusionSpanBenchmark.bs` (uncommitted). +- Create: `examples/rendererTest/src/source/Tests/OcclusionSpanMapBenchmark.bs` - times the engine's `BGE.OcclusionSpanMap` (k = 8/32/128 random polygons, 45 bands; build ms, query µs, brute-force false-hide check). Keep the spike's watchdog-safe one-config-per-few-frames structure and its visual check. +- Create: `examples/rendererTest/src/source/Tests/OcclusionSceneBenchmark.bs` - the occlusion-heavy scene below. +- Modify: `examples/rendererTest/src/source/DemoList.bs` - ids `occlusion-span-map-benchmark` and `occlusion-scene-benchmark`, category `Benchmarks` (or whatever category the existing benchmarks use). + +**Interfaces:** +- Consumes: `Renderer.markOccluder`, `occlusionCullingEnabled`, `getOccludedCountLastFrame()`, `showOcclusionDebug`. + +**The scene benchmark:** +- Built like `StaticGeometryBspBenchmark.bs`: a fresh off-screen renderer per phase, frames driven by frame index, timed with `roTimespan`. +- Contents: + - three large static wall rectangles (`orientedDrawBackFace`, filled), registered with `markStatic` + `markOccluder`; + - 60 `Image` billboards in `oriented` mode (texture warps are expensive) scattered behind and beside them; + - a camera orbiting on a path. +- Four phases: occlusion on/off × camera moving/still. +- Report per phase: ms per frame, draw calls per frame, occluded count. +- OK toggles `showOcclusionDebug` on a final live view. + +- [ ] **Step 1:** Write both demos and register them. Build: `cd examples/rendererTest && npx bsc --create-package=false`. Expected: 0 errors. +- [ ] **Step 2:** Package and sideload: `npm run package`, then `node node_modules/rokubot/dist/cli.js sideload ./examples/rendererTest/out/bge-rendererTest.zip --deleteDevChannel`, `press home`, `launch dev --param demo=occlusion-scene-benchmark`. Screenshot the results. + - Expected: false hides 0 in the span benchmark. + - Expected: the "occlusion on" phases have fewer draw calls than "off". + - Record the numbers for the PR. +- [ ] **Step 3:** With the overlay on, check visually that no red outline is drawn around an object that is partly visible. +- [ ] **Step 4:** Commit: `git commit -m "Add occlusion benchmarks to rendererTest (#108)"` + +--- + +### Task 10: Chapel occluder in terrain + +**Files:** +- Copy `/Users/mpearce/Downloads/bge aaa/export/chapel.obj`, `chapel.mtl` and `textures/*.png` to `examples/terrain/src/models/chapel/`. No credit file. +- Create: `examples/terrain/src/source/Entities/Chapel.bs` +- Modify: `examples/terrain/src/source/Scenes/WorldScene.bs` - `onLoad()` (line 12) and `onCreate()` +- Modify: `examples/terrain/src/source/Entities/TerrainFreeFlyCameraController.bs` (toggle key) + +**The Chapel entity:** +- `isStatic = true`. +- `addDrawable("chapel", new BGE.DrawableModel(m, game.getModel("chapel"), {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.solid}))`. Confirm the model accessor name on `Game`, and the right solid mode for a closed mesh. +- Scale about 25x, so the 13-unit model is ~330 tall against 180-tall trees. +- Place it on the ground between the start position and a dense cluster from `TreePlacements.bs`. + +**Loading:** in `WorldScene.onLoad()`, `loader.load3dModel("chapel", "pkg:/models/chapel/chapel.obj", {texturePath: "pkg:/models/chapel/textures/stone.png"})` when the model isn't already loaded. + +**Toggle:** +- A button not already used by the terrain controller or `BGE.FreeFlyCameraController` toggles `m.game.canvas.renderer.occlusionCullingEnabled`. Grep both for `isButton(` first; `play` is the first choice. +- Show `Occlusion: on/off` in the existing hint text. +- The existing options debug toggle also calls `game.debugDrawOcclusion()`. + +- [ ] **Step 1:** Implement, then `cd examples/terrain && npx bsc --create-package=false`. Expected: 0 errors. +- [ ] **Step 2:** Package, sideload and launch on the device. + - Screenshot: the chapel renders, and trees behind it disappear only when fully hidden. Use the overlay. + - Then ask the user to fly around and report popping. Real-time play through rokubot is too slow. +- [ ] **Step 3:** Measure FPS with occlusion on and off, camera still and moving (follow `reference_rokubot_fps_sampling`: first paused sample per launch, quality pinned). Record the numbers for the PR, whatever they show. +- [ ] **Step 4:** Commit: `git commit -m "Add an occluding chapel to terrain (#108)"` + +--- + +### Task 11: No-regression check, docs, PR + +**Files:** +- Modify: `CLAUDE.md` - a renderer bullet for occlusion: + - pipeline placement and the zero-cost fallback; + - the conservative rules; + - occluders draw exactly; + - the `VisibilityState` roll-up; + - `isOnScreen()` is now a wrapper. + + Also update the existing `isOnScreen()`/`isCulled()` mentions. +- Modify: `docs/drawables-and-scene-objects.md` - a consumer section "Occluders and visibility" with a short example: a static entity, `isOccluder: true`, and `getVisibility()`/`isOnScreen()` in game code. Lead with the how-to; put the gotchas (opaque only, oriented modes, `Camera3d`) as asides. +- Modify: the `rendererTest` row in `.claude/skills/rokubot-examples/SKILL.md`, and the terrain row (the new toggle key). + +- [ ] **Step 1: Baseline.** Build `examples/3d` from `main` and from this branch, and measure FPS on the device in the same scene. Expected: no regression. Record both. +- [ ] **Step 2:** Write the docs. `npm run docs` builds without errors. +- [ ] **Step 3:** `npm run check:all`. Expected: green. +- [ ] **Step 4:** Commit the docs, push the branch with an explicit refspec (`git push -u origin feature/issue-108-occlusion-culling:feature/issue-108-occlusion-culling`), and open the PR with the `pr-voice` skill. + - The body has the benchmark tables from Tasks 9-11. + - It says `Closes #108` and `Closes #342`. diff --git a/src/source/engine/Game.bs b/src/source/engine/Game.bs index 427b574a..64b7078c 100644 --- a/src/source/engine/Game.bs +++ b/src/source/engine/Game.bs @@ -1726,6 +1726,16 @@ namespace BGE end sub + ' Set if the occlusion culling overlay should be drawn on the game canvas, to spot objects + ' hidden by mistake. Covered spans are drawn as translucent blue-green bands, and every + ' object skipped this frame as hidden behind an occluder gets a red box. + ' + ' @param {boolean} enabled + sub debugDrawOcclusion(enabled as boolean) + m.canvas.renderer.showOcclusionDebug = enabled + end sub + + ' Set if Safe Zone should be drawn ' ' @param {boolean} enabled diff --git a/src/source/engine/GameEntity.bs b/src/source/engine/GameEntity.bs index 83475a9b..9c5a7b9a 100644 --- a/src/source/engine/GameEntity.bs +++ b/src/source/engine/GameEntity.bs @@ -1,3 +1,5 @@ +import "renderer/occlusion/OcclusionTypes.bs" +import "renderer/occlusion/VisibilityRank.bs" import "../math/Matrix44.bs" import "../math/vector.bs" import "../utils/colors.bs" @@ -129,29 +131,35 @@ namespace BGE end function - ' Whether this entity had at least one drawable actually drawn (not culled by the - ' camera frustum) as of the last frame. True for an entity with no drawables at all, - ' since there's no geometry to say it's off-screen - a purely logical entity (a spawner, - ' a game-state controller) shouldn't get silently treated as "off-screen" by code using - ' this to skip work. + ' The most visible state among this entity's drawables as of the last frame, in the + ' order visible > occluded > offCamera > unknown: visible if any was drawn, occluded if + ' something in front hid it, offCamera if the camera couldn't see it. unknown for an + ' entity with no drawables, or before its first frame. + ' + ' @return {BGE.VisibilityState} + function getVisibility() as BGE.VisibilityState + result = BGE.VisibilityState.unknown + for each drawableObj in m.drawables + result = BGE.mostVisibleState(result, drawableObj.getVisibility()) + end for + return result + end function + + + ' Whether this entity might be seen: true when it is visible or unknown (no drawables, + ' or not drawn yet), false when it is occluded or off camera. A purely logical entity (a + ' spawner, a game-state controller) is never treated as off-screen. ' ' This is a signal only - nothing in the engine uses it to skip an entity's own - ' onUpdate()/collision checks automatically, since a culled entity can still be + ' onUpdate()/collision checks automatically, since a hidden entity can still be ' relevant to gameplay (e.g. approaching from off-screen). It's meant for game code ' that wants to opt in to skipping its own expensive, purely-visual per-frame work for ' an entity that isn't currently visible. See issue #75. ' ' @return {boolean} function isOnScreen() as boolean - if m.drawables.count() = 0 - return true - end if - for each drawableObj in m.drawables - if drawableObj.isOnScreen() - return true - end if - end for - return false + state = m.getVisibility() + return state = BGE.VisibilityState.visible or state = BGE.VisibilityState.unknown end function diff --git a/src/source/engine/GameEntity.spec.bs b/src/source/engine/GameEntity.spec.bs index 101dbb94..5ff1c8fd 100644 --- a/src/source/engine/GameEntity.spec.bs +++ b/src/source/engine/GameEntity.spec.bs @@ -190,20 +190,22 @@ namespace tests m.assertEqual(10.0, emitter.spawnRate) end function - @describe("isOnScreen") + @describe("getVisibility / isOnScreen") - @it("is true when the entity has no drawables at all") + @it("is unknown with no drawables, and isOnScreen is true") function _() + m.assertEqual(BGE.VisibilityState.unknown, m.entity.getVisibility()) m.assertTrue(m.entity.isOnScreen()) end function - @it("reflects whether any of its drawables were actually drawn, not culled") + @it("isOnScreen is true for visible and unknown, false for occluded and offCamera") function _() - bitmap = CreateObject("roBitmap", {width: 200, height: 100, alphaEnable: true}) - renderer = new BGE.Renderer(bitmap) + renderer = new BGE.Renderer(CreateObject("roBitmap", {width: 200, height: 100, alphaEnable: true})) rect = new BGE.DrawableRectangle(m.entity, 40, 20) m.entity.addDrawable("rect", rect) sceneObj = rect.addToRenderer(renderer) + m.assertEqual(BGE.VisibilityState.unknown, m.entity.getVisibility()) + m.assertTrue(m.entity.isOnScreen()) ' The default Camera2d centers on its canvas, so world (100, 50) is canvas (100, 50) - ' a 40x20 rectangle there is well inside the 200x100 bitmap. @@ -211,12 +213,18 @@ namespace tests m.entity.updateTransformationMatrix() sceneObj.update(renderer.camera) sceneObj.draw(renderer) + m.assertEqual(BGE.VisibilityState.visible, m.entity.getVisibility()) m.assertTrue(m.entity.isOnScreen()) + sceneObj.markSkippedOccluded(renderer.camera) + m.assertEqual(BGE.VisibilityState.occluded, m.entity.getVisibility()) + m.assertFalse(m.entity.isOnScreen()) + m.entity.position = BGE.Math.VectorOps.create(100000, 0, 0) m.entity.updateTransformationMatrix() sceneObj.update(renderer.camera) sceneObj.draw(renderer) + m.assertEqual(BGE.VisibilityState.offCamera, m.entity.getVisibility()) m.assertFalse(m.entity.isOnScreen()) end function diff --git a/src/source/engine/drawables/AnimatedImage.bs b/src/source/engine/drawables/AnimatedImage.bs index 44a97f15..4b1fd318 100644 --- a/src/source/engine/drawables/AnimatedImage.bs +++ b/src/source/engine/drawables/AnimatedImage.bs @@ -47,8 +47,14 @@ namespace BGE end if if m.regions <> invalid and m.index >= 0 and m.index < m.regions.Count() m.region = m.regions[m.index] - m.width = m.region.getWidth() - m.height = m.region.getHeight() + newWidth = m.region.getWidth() + newHeight = m.region.getHeight() + ' a frame of a different size changes shape without moving + if newWidth <> m.width or newHeight <> m.height + m.invalidateGeometry() + end if + m.width = newWidth + m.height = newHeight m.applyAnchorToRegion() if "" <> m.sceneObjectAnimId and invalid <> m.sceneObjects[m.sceneObjectAnimId] diff --git a/src/source/engine/drawables/Drawable.bs b/src/source/engine/drawables/Drawable.bs index baf715c8..d985638f 100644 --- a/src/source/engine/drawables/Drawable.bs +++ b/src/source/engine/drawables/Drawable.bs @@ -1,3 +1,5 @@ +import "../renderer/occlusion/OcclusionTypes.bs" +import "../renderer/occlusion/VisibilityRank.bs" import "../../math/Matrix44.bs" import "../../math/vector.bs" import "../../utils/colors.bs" @@ -93,6 +95,26 @@ namespace BGE drawMode as SceneObjectDrawMode = SceneObjectDrawMode.matchCamera + ' Makes this drawable hide whatever sits behind it (occlusion culling). Only takes + ' effect when the owning GameEntity is `isStatic`, the drawable is fully opaque + ' (an Image, Rectangle or Model3d), its `drawMode` is an oriented one (not + ' directToCamera/directScaled), and the game uses a Camera3d. Otherwise it's ignored + ' and the drawable draws as normal. + ' + ' Only flag a drawable that is fully opaque: an Image with no transparent pixels, a + ' model with no translucent face colors. The engine can't check this, and anything + ' behind a see-through occluder will vanish. Note that a large filled triangle can + ' leave dotted single-pixel seams along its internal edges, which occlusion still + ' treats as covered. + ' + ' An occluder still uses the renderer's cheaper approximate drawing while it or the + ' camera moves. Its covered area shrinks to allow for that, so something just inside + ' its edge may keep drawing until the camera stops. See Renderer.occluderDrawPolicy + ' to change that trade-off. + isOccluder as boolean = false + + private warnedOccluderNotStatic as boolean = false + isShaded as boolean = false ' The darkest an `isShaded` surface is ever allowed to get, as a 0-1 fraction of its @@ -229,6 +251,15 @@ namespace BGE return m.owner?.game end function + ' Logs once per drawable that isOccluder was ignored because its owner isn't static. + sub warnOccluderNeedsStaticOwner() + if m.warnedOccluderNotStatic + return + end if + m.warnedOccluderNotStatic = true + m.log("Drawable '" + m.name + "' has isOccluder set but its GameEntity isn't isStatic - it will not occlude anything", BGE.Debug.LogLevel.warning) + end sub + ' Logs a message through the owner's Game. Does nothing if there is no Game to log to. ' ' @param {string} message @@ -369,24 +400,17 @@ namespace BGE end for end sub - ' Whether this drawable was actually drawn (not culled) in at least one of the - ' renderers it's registered with, as of the last frame. True before this has ever been - ' added to a renderer (nothing to say it's off-screen yet), so game code can use this - ' to skip expensive per-frame work for an off-screen entity without it defaulting to - ' "invisible" before the first frame ever draws. See GameEntity.isOnScreen() and - ' issue #75. + ' The most visible state among this drawable's scene objects, in the order visible > + ' occluded > offCamera > unknown, as of the last frame. unknown before it has been added + ' to a renderer. See GameEntity.getVisibility(). ' - ' @return {boolean} - function isOnScreen() as boolean - if m.sceneObjects.count() = 0 - return true - end if + ' @return {BGE.VisibilityState} + function getVisibility() as BGE.VisibilityState + result = BGE.VisibilityState.unknown for each item in m.sceneObjects.items() - if not (item.value as SceneObject).isCulled() - return true - end if + result = BGE.mostVisibleState(result, (item.value as SceneObject).getVisibility()) end for - return false + return result end function end class diff --git a/src/source/engine/drawables/Drawable.spec.bs b/src/source/engine/drawables/Drawable.spec.bs index 66619e26..9a85cc9a 100644 --- a/src/source/engine/drawables/Drawable.spec.bs +++ b/src/source/engine/drawables/Drawable.spec.bs @@ -158,33 +158,30 @@ namespace tests m.assertTrue(rect.geometryVersion > before) end function - @describe("isOnScreen") + @describe("getVisibility") - @it("is true before this drawable has ever been added to a renderer") + @it("is unknown with no scene objects") function _() - m.assertTrue(m.drawable.isOnScreen()) + m.assertEqual(BGE.VisibilityState.unknown, m.drawable.getVisibility()) end function - @it("reflects whether its scene object was culled") + @it("reports the most visible state among its scene objects") function _() - bitmap = CreateObject("roBitmap", {width: 200, height: 100, alphaEnable: true}) - renderer = new BGE.Renderer(bitmap) + rendererA = new BGE.Renderer(CreateObject("roBitmap", {width: 200, height: 100, alphaEnable: true})) rect = new BGE.DrawableRectangle(m.entity, 40, 20) - sceneObj = rect.addToRenderer(renderer) + first = rect.addToRenderer(rendererA) + second = rect.addToRenderer(rendererA) - ' The default Camera2d centers on its canvas, so world (100, 50) is canvas (100, 50) - - ' a 40x20 rectangle there is well inside the 200x100 bitmap. - m.entity.position = BGE.Math.VectorOps.create(100, 50, 0) - m.entity.updateTransformationMatrix() - sceneObj.update(renderer.camera) - sceneObj.draw(renderer) - m.assertTrue(rect.isOnScreen()) + first.markSkippedOccluded(rendererA.camera) + second.markSkippedBehindCamera(rendererA.camera) + m.assertEqual(BGE.VisibilityState.occluded, rect.getVisibility()) - m.entity.position = BGE.Math.VectorOps.create(100000, 0, 0) + ' Drawing one of them inside the view makes the drawable visible. + m.entity.position = BGE.Math.VectorOps.create(100, 50, 0) m.entity.updateTransformationMatrix() - sceneObj.update(renderer.camera) - sceneObj.draw(renderer) - m.assertFalse(rect.isOnScreen()) + second.update(rendererA.camera) + second.draw(rendererA) + m.assertEqual(BGE.VisibilityState.visible, rect.getVisibility()) end function end class diff --git a/src/source/engine/drawables/Image.spec.bs b/src/source/engine/drawables/Image.spec.bs index 2a70a6e0..e1efbd1a 100644 --- a/src/source/engine/drawables/Image.spec.bs +++ b/src/source/engine/drawables/Image.spec.bs @@ -61,6 +61,28 @@ namespace tests m.assertEqual(-30, regionB.GetPretranslationY()) end function + @it("invalidates its geometry when a frame swap changes its size") + function _() + regionA = m.newRegion(20, 10) + regionB = CreateObject("roRegion", m.sourceBitmap, 20, 0, 40, 30) + animImage = new BGE.AnimatedImage(m.entity, [regionA, regionB]) + versionBefore = animImage.geometryVersion + animImage.index = 1 + animImage.update() + m.assertTrue(animImage.geometryVersion > versionBefore, `geometryVersion stayed ${animImage.geometryVersion}`) + end function + + @it("leaves its geometry alone when a frame swap keeps its size") + function _() + regionA = m.newRegion(20, 10) + regionB = CreateObject("roRegion", m.sourceBitmap, 20, 0, 20, 10) + animImage = new BGE.AnimatedImage(m.entity, [regionA, regionB]) + versionBefore = animImage.geometryVersion + animImage.index = 1 + animImage.update() + m.assertEqual(versionBefore, animImage.geometryVersion) + end function + end class end namespace diff --git a/src/source/engine/drawables/Model3d.bs b/src/source/engine/drawables/Model3d.bs index 9c37f492..f6ff7b52 100644 --- a/src/source/engine/drawables/Model3d.bs +++ b/src/source/engine/drawables/Model3d.bs @@ -163,6 +163,9 @@ namespace BGE class DrawableModel extends Drawable + ' The model to draw. After replacing it or changing its faces, call + ' invalidateGeometry() so the renderer redraws it (and, for an occluder, rebuilds the + ' area it hides). model as Model3d ' How many consecutive frames a face may reuse its own last-rendered raster diff --git a/src/source/engine/renderer/Renderer.bs b/src/source/engine/renderer/Renderer.bs index 792d3ba9..29d91c5d 100644 --- a/src/source/engine/renderer/Renderer.bs +++ b/src/source/engine/renderer/Renderer.bs @@ -1,3 +1,5 @@ +import "occlusion/OcclusionTypes.bs" +import "occlusion/OcclusionSpanMap.bs" import "../quality/RenderQualityLevel.bs" import "../quality/RenderQualityPresets.bs" import "../quality/RenderQualitySettings.bs" @@ -15,6 +17,7 @@ import "../interfaces.bs" import "bsp/StaticGeometryBSP.bs" import "cameras/Camera.bs" import "cameras/Camera2d.bs" +import "cameras/Camera3d.bs" import "DepthSortHelpers.bs" import "RendererHelpers.bs" import "sceneObjects/SceneObject.bs" @@ -123,6 +126,47 @@ namespace BGE ' entirely (lines, planes, or anything else with a degenerate bounding-point set). public computeOverlapClusters as boolean = false + ' Whether static drawables flagged isOccluder hide what's behind them. + public occlusionCullingEnabled as boolean = true + ' Number of vertical bands in the occlusion span map. + public occlusionBandCount as integer = 45 + ' The most occluders considered per frame. + public maxOccluders as integer = 32 + ' Pixels added around each tested object's canvas box (plus its outline width) before + ' it's checked against the occluders, so an object near an occluder's edge still draws. + public occlusionMargin as float = 2.0 + ' Whether to draw the occlusion debug overlay (see Game.debugDrawOcclusion()). + public showOcclusionDebug as boolean = false + ' How occluders trade exactness for speed - see BGE.OccluderDrawPolicy. Takes effect + ' from the next render(); every cached drawing is redrawn once when it changes. + public occluderDrawPolicy as BGE.OccluderDrawPolicy = BGE.OccluderDrawPolicy.fastShrink + ' The policy last handed to the registered occluders. + private appliedOccluderDrawPolicy as BGE.OccluderDrawPolicy = BGE.OccluderDrawPolicy.fastShrink + + ' Registered occluder scene objects by id. + private occluders as roAssociativeArray = {} + private occludersChanged as boolean = false + private occlusionMap as BGE.OcclusionSpanMap = invalid + ' Bumped on every map build, so scene objects know their verdict is stale. + private occlusionMapVersion as integer = 0 + private occlusionMapBuildCount as integer = 0 + private occludedCountLastFrame as integer = 0 + private occlusionMsLastFrame as integer = 0 + private occlusionTestsLastFrame as integer = 0 + private occlusionTimer as roTimespan = CreateObject("roTimespan") + ' Scratch for projected occlusion test points: x, y, depth per point. + private occlusionProjected as float[] = [] + private occlusionDepthPoint as BGE.Math.Vector[] = [invalid] + ' Whether updateOcclusion() did any work last frame - so turning it off clears verdicts once. + private occlusionWasActive as boolean = false + ' What the current map was built for - any change forces a rebuild. + private occlusionMapWidth as integer = 0 + private occlusionMapHeight as integer = 0 + private occlusionMapBandCount as integer = 0 + private occlusionMapProjectionVersion as integer = -1 + private occlusionMapMaxOccluders as integer = -1 + private occlusionMapMargin as float = -1.0 + camera as Camera triangleCache = new TriangleCache(5) @@ -357,6 +401,18 @@ namespace BGE return m.lossyTriangleFillCount end function + ' How far, in pixels, a triangle filled with allowQuickDraw may fall short of its exact + ' edges: the coarse band size, or 0 when quick drawing never coarsens a fill. + ' + ' @return {float} + function getQuickDrawFillError() as float + bandStep = m.triangleQuickDrawParams.step + if bandStep > 1 + return bandStep * 1.0 + end if + return 0.0 + end function + private sub updateTriangleQuickDrawParams() bandStep = m.qualitySettings.triangleQuickDrawStep if bandStep < 1 @@ -433,8 +489,122 @@ namespace BGE if owner <> invalid and owner.isStatic m.markStatic(sceneObj) end if + + m.syncOccluderFlag(sceneObj) + end sub + + ' Registers or unregisters an owned scene object as an occluder when its Drawable's + ' isOccluder changes (checked every frame). Ownerless scene objects opt in by calling + ' markOccluder() directly, so they're left alone. + private sub syncOccluderFlag(sceneObj as SceneObject) + drawable = sceneObj.drawable + if drawable.isOccluder = sceneObj.occluderFlagSeen + return + end if + owner = drawable.owner + if owner = invalid + return + end if + sceneObj.occluderFlagSeen = drawable.isOccluder + if not drawable.isOccluder + m.unmarkOccluder(sceneObj) + else if owner.isStatic + m.markOccluder(sceneObj) + else + drawable.warnOccluderNeedsStaticOwner() + end if + end sub + + private sub unmarkOccluder(sceneObj as SceneObject) + if sceneObj.isOccluder + m.occluders.delete(sceneObj.id) + sceneObj.isOccluder = false + m.occludersChanged = true + end if + end sub + + ' Registers a SceneObject as an occluder, so it can hide objects behind it. Called + ' automatically by addSceneObject() for a Drawable with isOccluder set on a static + ' entity - exposed publicly so a SceneObject with no owning GameEntity can opt in + ' directly. Declines (with a warning) a type that can't occlude, or a drawable whose + ' own drawMode is screen-aligned. + ' + ' @param {SceneObject} sceneObj + sub markOccluder(sceneObj as SceneObject) + drawable = sceneObj.drawable + if not sceneObj.isEligibleOccluderType() + drawable.log("Drawable '" + drawable.name + "' has isOccluder set but its " + sceneObj.type.toStr() + " scene object type can't occlude (only Image, Rectangle and Model can) - it will draw normally", BGE.Debug.LogLevel.warning) + return + end if + if isScreenAlignedDrawMode(drawable.drawMode) + drawable.log("Drawable '" + drawable.name + "' has isOccluder set but uses a screen-aligned draw mode, which can't occlude - it will draw normally", BGE.Debug.LogLevel.warning) + return + end if + sceneObj.prepareOccluder() + sceneObj.isOccluder = true + sceneObj.occluderDrawPolicy = m.occluderDrawPolicy + m.occluders[sceneObj.id] = sceneObj + m.occludersChanged = true + end sub + + ' Hands a changed occluderDrawPolicy to every occluder. Bumping qualityVersion redraws + ' the cached bitmaps (drawn under the old policy) and rebuilds the occlusion map. + private sub applyOccluderDrawPolicy() + if m.occluderDrawPolicy = m.appliedOccluderDrawPolicy + return + end if + m.appliedOccluderDrawPolicy = m.occluderDrawPolicy + for each id in m.occluders + occluder = m.occluders[id] as SceneObject + occluder.occluderDrawPolicy = m.occluderDrawPolicy + end for + m.qualityVersion++ end sub + ' Whether any occluder is currently registered. + ' + ' @return {boolean} + function hasOccluders() as boolean + return m.occluders.count() > 0 + end function + + ' The occlusion span map built from this frame's occluders, for debug drawing and tests. + ' + ' @return {BGE.OcclusionSpanMap} invalid until occlusion culling has first had work to do + function getOcclusionMap() as BGE.OcclusionSpanMap + return m.occlusionMap + end function + + ' How many times the occlusion map has been built. It's only rebuilt when the camera, + ' an occluder, the canvas or an occlusion setting changes. + ' + ' @return {integer} + function getOcclusionMapBuildCount() as integer + return m.occlusionMapBuildCount + end function + + ' How many scene objects occlusion culling hid in the last rendered frame. + ' + ' @return {integer} + function getOccludedCountLastFrame() as integer + return m.occludedCountLastFrame + end function + + ' How long occlusion culling took in the last rendered frame, in whole milliseconds. + ' + ' @return {integer} + function getOcclusionMsLastFrame() as integer + return m.occlusionMsLastFrame + end function + + ' How many scene objects were fully tested against the occlusion map in the last + ' rendered frame - those not ruled out by a cheaper check first. + ' + ' @return {integer} + function getOcclusionTestsLastFrame() as integer + return m.occlusionTestsLastFrame + end function + ' Marks a SceneObject as static geometry and schedules a tree rebuild. Called ' automatically by addSceneObject() when the object's owning GameEntity is isStatic - ' exposed publicly so a SceneObject with no owning GameEntity (e.g. @@ -452,7 +622,9 @@ namespace BGE sub markStatic(sceneObj as SceneObject) if not sceneObj.isEligibleForStaticBsp() owner = sceneObj.drawable.owner - if owner <> invalid + ' an occluder only needs isStatic to occlude, so a static model occluder is expected + isOccluderOnly = sceneObj.drawable.isOccluder and sceneObj.isEligibleOccluderType() + if owner <> invalid and not isOccluderOnly sceneObj.drawable.log("GameEntity '" + owner.name + "' is marked isStatic but its " + sceneObj.type.toStr() + " scene object type doesn't support static geometry yet (v1 supports SceneObjectImage/SceneObjectRectangle only) - it will draw using the normal sorted order instead", BGE.Debug.LogLevel.warning) end if return @@ -486,6 +658,7 @@ namespace BGE if sceneObjToRemove.isStatic m.needsStaticTreeRebuild = true end if + m.unmarkOccluder(sceneObjToRemove) end if sceneObjToRemove.id = "" end sub @@ -505,7 +678,7 @@ namespace BGE private function getClusterCandidates() as BGE.SceneObject[] candidates = [] for each sceneObj in m.sceneObjects - if sceneObj.isEnabled() and not sceneObj.isCulled() and sceneObj.participatesInOverlapDetection() + if sceneObj.isEnabled() and not sceneObj.isCulled() and not sceneObj.occludedThisFrame and sceneObj.participatesInOverlapDetection() candidates.push(sceneObj) end if end for @@ -520,6 +693,7 @@ namespace BGE if sceneObj.checkStaticEnabledStateChanged() m.needsStaticTreeRebuild = true end if + m.syncOccluderFlag(sceneObj) if sceneObj.isEnabled() sceneObj.update(m.camera) if sceneObj.depthChangedThisFrame @@ -534,6 +708,162 @@ namespace BGE end for end sub + ' Judges which scene objects this frame's occluders hide (see SceneObject.occludedThisFrame), + ' rebuilding the span map only when something it depends on changed. + private sub updateOcclusion() + wasActive = m.occlusionWasActive + m.occludedCountLastFrame = 0 + m.occlusionTestsLastFrame = 0 + if not m.occlusionCullingEnabled or not m.hasOccluders() or m.camera.name <> "Camera3d" + if wasActive + for each sceneObj in m.sceneObjects + m.clearOcclusionVerdict(sceneObj) + end for + m.occlusionWasActive = false + end if + return + end if + m.occlusionWasActive = true + + width = m.draw2d.getWidth() + height = m.draw2d.getHeight() + needsBuild = not wasActive or m.occludersChanged or m.camera.movedLastFrame() or m.camera.projectionVersion <> m.occlusionMapProjectionVersion or m.maxOccluders <> m.occlusionMapMaxOccluders or m.occlusionMargin <> m.occlusionMapMargin + if invalid = m.occlusionMap or width <> m.occlusionMapWidth or height <> m.occlusionMapHeight or m.occlusionBandCount <> m.occlusionMapBandCount + m.occlusionMap = new BGE.OcclusionSpanMap(width, height, BGE.Math.Max(1, m.occlusionBandCount)) + m.occlusionMapWidth = width + m.occlusionMapHeight = height + m.occlusionMapBandCount = m.occlusionBandCount + needsBuild = true + end if + ' every occluder is asked, so each one's change tracking stays current + for each id in m.occluders + occluder = m.occluders[id] as SceneObject + if occluder.occluderChangedThisFrame(m) + needsBuild = true + end if + end for + + if needsBuild + m.buildOcclusionMap() + end if + + for each sceneObj in m.sceneObjects + if m.isOcclusionCandidate(sceneObj) + if sceneObj.occlusionMapVersion <> m.occlusionMapVersion or sceneObj.canvasShapeMayHaveChanged() + sceneObj.occludedThisFrame = false + sceneObj.occlusionTestBounds = invalid + if m.mayBeHiddenByOccluders(sceneObj) + m.occlusionTestsLastFrame++ + sceneObj.occludedThisFrame = m.isHiddenByOccluders(sceneObj) + end if + sceneObj.occlusionMapVersion = m.occlusionMapVersion + end if + if sceneObj.occludedThisFrame + m.occludedCountLastFrame++ + end if + else + m.clearOcclusionVerdict(sceneObj) + end if + end for + end sub + + private sub buildOcclusionMap() + polygons = [] as BGE.OccluderPolygon[] + for each id in m.occluders + (m.occluders[id] as SceneObject).appendOccluderPolygons(m, polygons) + end for + if polygons.count() > m.maxOccluders + polygons.SortBy("area", "r") + largest = [] as BGE.OccluderPolygon[] + for i = 0 to m.maxOccluders - 1 + largest.push(polygons[i]) + end for + polygons = largest + end if + m.occlusionMap.build(polygons) + m.occlusionMapVersion++ + m.occlusionMapBuildCount++ + m.occludersChanged = false + m.occlusionMapProjectionVersion = m.camera.projectionVersion + m.occlusionMapMaxOccluders = m.maxOccluders + m.occlusionMapMargin = m.occlusionMargin + end sub + + ' Planes, skyboxes and parallax layers draw under everything; occluders always draw. + ' An object latched off camera stays off camera, so its cull latch isn't released. + private function isOcclusionCandidate(sceneObj as SceneObject) as boolean + objType = sceneObj.type + ' cheapest checks first + return sceneObj.negDistanceFromCamera < 0 and not sceneObj.isOccluder and sceneObj.distanceFadeAlpha > 0 and objType <> SceneObjectType.Plane and objType <> SceneObjectType.Skybox and objType <> SceneObjectType.ParallaxLayer and not sceneObj.isCulled() and sceneObj.isEnabled() and sceneObj.canBeOccluded() + end function + + private sub clearOcclusionVerdict(sceneObj as SceneObject) + sceneObj.occludedThisFrame = false + sceneObj.occlusionMapVersion = -1 + sceneObj.occlusionTestBounds = invalid + end sub + + ' A cheap first check by the object's depth position, which lies within what it draws: + ' an object reaching nearer than every occluder, or whose position isn't covered by a + ' nearer occluder, can't be hidden. Only rules objects out, so it never hides anything. + private function mayBeHiddenByOccluders(sceneObj as SceneObject) as boolean + depth = -sceneObj.negDistanceFromCamera + if depth <= m.occlusionMap.getNearestDepth() + return false + end if + projected = m.occlusionProjected + m.occlusionDepthPoint[0] = sceneObj.getDepthPosition() + if not (m.camera as Camera3d).projectPointsWithDepth(m.occlusionDepthPoint, projected) + return false + end if + return m.occlusionMap.isPointCovered(projected[0], projected[1], depth) + end function + + ' Whether every point the object could draw at is behind the occluders. Any point that + ' doesn't project means it can't be judged, so it isn't hidden. + private function isHiddenByOccluders(sceneObj as SceneObject) as boolean + sceneObj.occlusionTestBounds = invalid + points = sceneObj.getOcclusionTestPoints(m.camera) + if invalid = points or points.count() = 0 + return false + end if + projected = m.occlusionProjected + if not (m.camera as Camera3d).projectPointsWithDepth(points, projected) + return false + end if + minX = projected[0] + maxX = minX + minY = projected[1] + maxY = minY + nearest = projected[2] + for i = 3 to projected.count() - 1 step 3 + x = projected[i] + y = projected[i + 1] + depth = projected[i + 2] + if x < minX + minX = x + else if x > maxX + maxX = x + end if + if y < minY + minY = y + else if y > maxY + maxY = y + end if + if depth < nearest + nearest = depth + end if + end for + margin = m.occlusionMargin + ' an outline is stroked across the object's edge + if sceneObj.drawable.hasOutline() + margin += sceneObj.drawable.outlineWidth + end if + bounds = {minX: minX - margin, minY: minY - margin, maxX: maxX + margin, maxY: maxY + margin} as BGE.OcclusionTestBounds + sceneObj.occlusionTestBounds = bounds + return m.occlusionMap.isHidden(bounds.minX, bounds.minY, bounds.maxX, bounds.maxY, nearest) + end function + ' Draws every deferred multi-member-cluster object's primitives in one combined ' depth order, then calls afterDraw() once per object (matching the normal ' single-object draw path's bookkeeping). Called once per frame, at the end of @@ -631,7 +961,11 @@ namespace BGE ' so this check (matching the legacy sorted path's own isEnabled() gate) is ' what keeps a disabled object from drawing out of a stale tree in the meantime. if sceneObj.isEnabled() - sceneObj.draw(m) + if sceneObj.occludedThisFrame + sceneObj.markSkippedOccluded(m.camera) + else + sceneObj.draw(m) + end if end if end for end sub @@ -699,7 +1033,12 @@ namespace BGE ' this renderer's surface. `Game` calls this for you each frame; call it yourself ' only when using a Renderer without a Game. sub render() + m.applyOccluderDrawPolicy() m.updateSceneObjects() + ' a free-running timer, so both readings round down alike and averages stay unbiased + occlusionStart = m.occlusionTimer.TotalMilliseconds() + m.updateOcclusion() + m.occlusionMsLastFrame = m.occlusionTimer.TotalMilliseconds() - occlusionStart if m.computeOverlapClusters clusterResult = BGE.DepthSort.groupIntoClusters(m.getClusterCandidates(), m, m.lastClusterSortOrder) @@ -786,10 +1125,12 @@ namespace BGE ' ignore any that are too far away (TBD) or behind camera for each sceneObj in m.sceneObjects if sceneObj.isEnabled() and sceneObj.type <> SceneObjectType.Plane and sceneObj.type <> SceneObjectType.Skybox - if sceneObj.negDistanceFromCamera < 0 - sceneObj.draw(m) - else + if sceneObj.negDistanceFromCamera >= 0 sceneObj.markSkippedBehindCamera(m.camera) + else if sceneObj.occludedThisFrame + sceneObj.markSkippedOccluded(m.camera) + else + sceneObj.draw(m) end if end if end for @@ -797,6 +1138,10 @@ namespace BGE m.drawPendingClusterPrimitives() + if m.showOcclusionDebug + m.drawOcclusionDebug() + end if + ' Every other path that draws into a bitmap and relies on reading it back ' afterward (a further draw, or SwapBuffers()) already forces completion via ' forceDraw() (see makeIntoTriangle/drawPinnedCornersTo/etc and @@ -815,6 +1160,30 @@ namespace BGE end sub + ' Draws the covered spans as translucent rectangles and each hidden object's tested box in red. + private sub drawOcclusionDebug() + ' the map is left as it was when occlusion stopped running + if m.occlusionWasActive and m.occlusionMap <> invalid + bandHeight = m.occlusionMap.getBandHeight() + for band = 0 to m.occlusionMap.getBandCount() - 1 + spans = m.occlusionMap.getBandSpans(band) + for i = 0 to spans.count() - 3 step 3 + m.drawRectangle(spans[i], band * bandHeight, spans[i + 1] - spans[i], bandHeight, &h3060C080) + end for + end for + end if + for each sceneObj in m.sceneObjects + bounds = sceneObj.occlusionTestBounds + if sceneObj.occludedThisFrame and bounds <> invalid + m.drawLine(bounds.minX, bounds.minY, bounds.maxX, bounds.minY, BGE.Colors.Red) + m.drawLine(bounds.maxX, bounds.minY, bounds.maxX, bounds.maxY, BGE.Colors.Red) + m.drawLine(bounds.maxX, bounds.maxY, bounds.minX, bounds.maxY, BGE.Colors.Red) + m.drawLine(bounds.minX, bounds.maxY, bounds.minX, bounds.minY, BGE.Colors.Red) + end if + end for + end sub + + sub onSwapBuffers() m.frameCount++ if m.frameCount = 30 diff --git a/src/source/engine/renderer/RendererOcclusion.spec.bs b/src/source/engine/renderer/RendererOcclusion.spec.bs new file mode 100644 index 00000000..6ffd36d3 --- /dev/null +++ b/src/source/engine/renderer/RendererOcclusion.spec.bs @@ -0,0 +1,1220 @@ +namespace tests + + @suite("BGE.Renderer - occlusion") + class RendererOcclusionTests extends rooibos.BaseTestSuite + + game as BGE.Game + renderer as BGE.Renderer + + protected override function beforeEach() + m.game = new BGE.Game(320, 240) + m.renderer = m.game.canvas.renderer + end function + + ' A cube from 0 to `size` on each axis, each face wound counter-clockwise from outside. + private function cubeModel(size = 1 as float) as BGE.Model3d + faces = [] + quads = [ + [[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]], + [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], + [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]], + [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]], + [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]], + [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] + ] + for each q in quads + for each tri in [[q[0], q[1], q[2]], [q[0], q[2], q[3]]] + faces.push(BGE.Model3dFaceOps.create([ + BGE.Math.VectorOps.create(tri[0][0] * size, tri[0][1] * size, tri[0][2] * size), + BGE.Math.VectorOps.create(tri[1][0] * size, tri[1][1] * size, tri[1][2] * size), + BGE.Math.VectorOps.create(tri[2][0] * size, tri[2][1] * size, tri[2][2] * size) + ])) + end for + end for + return new BGE.Model3d(faces) + end function + + private function newCamera3d(position as BGE.Math.Vector, target = BGE.Math.VectorOps.create() as BGE.Math.Vector) as BGE.Camera3d + camera = new BGE.Camera3d() + camera.position = position + camera.setTarget(target) + camera.drawDistanceFadeFraction = 0 + camera.drawDistanceTransitionSeconds = 0 + return camera + end function + + private function newRenderer3d(camera as BGE.Camera3d) as BGE.Renderer + bitmap = CreateObject("roBitmap", {width: 320, height: 240, alphaEnable: true}) + return new BGE.Renderer(bitmap, m.game, camera) + end function + + ' A 200x200 static wall centred on the origin in the z=0 plane, registered as an occluder. + private function newWall(renderer as BGE.Renderer, args = {} as roAssociativeArray) as BGE.SceneObject + entity = new BGE.GameEntity(m.game, {name: "wall", isStatic: true}) + entity.position = BGE.Math.VectorOps.create(-100, 100, 0) + entity.updateTransformationMatrix() + drawArgs = {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace} + drawArgs.append(args) + drawable = new BGE.DrawableRectangle(entity, 200, 200, drawArgs) + return drawable.addToRenderer(renderer) + end function + + ' A cube centred on the origin, registered as an occluder. + private function newCube(renderer as BGE.Renderer, size = 100 as float) as BGE.SceneObjectModel + entity = new BGE.GameEntity(m.game, {name: "cube", isStatic: true}) + entity.position = BGE.Math.VectorOps.create(-size / 2, -size / 2, -size / 2) + entity.updateTransformationMatrix() + drawable = new BGE.DrawableModel(entity, m.cubeModel(size), {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.solid}) + return drawable.addToRenderer(renderer) as BGE.SceneObjectModel + end function + + ' A static occluder wall in the z=0 plane, centred on (centerX, 0). + private function newOccluderWall(renderer as BGE.Renderer, centerX as float, width as float, height as float) as BGE.SceneObject + entity = new BGE.GameEntity(m.game, {name: "wall", isStatic: true}) + entity.position = BGE.Math.VectorOps.create(centerX - width / 2, height / 2, 0) + entity.updateTransformationMatrix() + drawable = new BGE.DrawableRectangle(entity, width, height, {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace}) + return drawable.addToRenderer(renderer) + end function + + ' A small non-static rectangle centred on (x, 0, z), with a spy counting performDraw() calls. + private function newTree(renderer as BGE.Renderer, x as float, z as float, width = 20 as float, height = 20 as float) as BGE.SceneObject + entity = new BGE.GameEntity(m.game, {name: "tree"}) + entity.position = BGE.Math.VectorOps.create(x - width / 2, height / 2, z) + entity.updateTransformationMatrix() + drawable = new BGE.DrawableRectangle(entity, width, height, {drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace}) + sceneObj = drawable.addToRenderer(renderer) + spy = sceneObj as dynamic + spy.performDrawCount = 0 + spy.realPerformDraw = spy.performDraw + spy.performDraw = function(rendererObj as object, drawMode as integer) as boolean + m.performDrawCount++ + return m.realPerformDraw(rendererObj, drawMode) + end function + return sceneObj + end function + + ' A directScaled rectangle at (0, 0, 200) whose own plane is turned edge-on to the camera. + private function newEdgeOnSprite(renderer as BGE.Renderer, width as float, yaw as float) as BGE.SceneObject + entity = new BGE.GameEntity(m.game, {name: "sprite"}) + entity.position = BGE.Math.VectorOps.create(0, 0, 200) + entity.rotation = BGE.Math.VectorOps.create(0, yaw, 0) + entity.updateTransformationMatrix() + drawable = new BGE.DrawableRectangle(entity, width, 20, {drawMode: BGE.SceneObjectDrawMode.directScaled}) + return drawable.addToRenderer(renderer) + end function + + private function performDrawCount(sceneObj as BGE.SceneObject) as integer + return (sceneObj as dynamic).performDrawCount + end function + + ' Renders frames the way Game does, ticking each owning entity's transform first. + private sub renderFrames(renderer as BGE.Renderer, count = 2 as integer) + for i = 1 to count + for each sceneObj in renderer.getSceneObjectsCopy() + owner = sceneObj.drawable.owner + if owner <> invalid + owner.updateTransformationMatrix() + end if + end for + renderer.setupCameraForFrame() + renderer.render() + end for + end sub + + ' A renderer with the camera at (0, 0, -500) looking at the origin. + private function newOcclusionRenderer() as BGE.Renderer + return m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, -500))) + end function + + private function renderAndCollect(renderer as BGE.Renderer, sceneObj as BGE.SceneObject) as BGE.OccluderPolygon[] + renderer.setupCameraForFrame() + renderer.render() + polygons = [] as BGE.OccluderPolygon[] + sceneObj.appendOccluderPolygons(renderer, polygons) + return polygons + end function + + ' The canvas bounds of everything an occluder claims this frame. + private function claimedBounds(renderer as BGE.Renderer, sceneObj as BGE.SceneObject) as BGE.OcclusionTestBounds + polygons = [] as BGE.OccluderPolygon[] + sceneObj.appendOccluderPolygons(renderer, polygons) + bounds = {minX: 1e9, minY: 1e9, maxX: -1e9, maxY: -1e9} + for each polygon in polygons + for i = 0 to polygon.xs.count() - 1 + bounds.minX = BGE.Math.Min(bounds.minX, polygon.xs[i]) + bounds.maxX = BGE.Math.Max(bounds.maxX, polygon.xs[i]) + bounds.minY = BGE.Math.Min(bounds.minY, polygon.ys[i]) + bounds.maxY = BGE.Math.Max(bounds.maxY, polygon.ys[i]) + end for + end for + return bounds + end function + + ' The canvas bounds of world points projected through the renderer's camera. + private function projectedBounds(renderer as BGE.Renderer, worldPoints as BGE.Math.Vector[]) as BGE.OcclusionTestBounds + bounds = {minX: 1e9, minY: 1e9, maxX: -1e9, maxY: -1e9} + for each worldPoint in worldPoints + p = renderer.camera.worldPointToCanvasPoint(worldPoint) + bounds.minX = BGE.Math.Min(bounds.minX, p.x) + bounds.maxX = BGE.Math.Max(bounds.maxX, p.x) + bounds.minY = BGE.Math.Min(bounds.minY, p.y) + bounds.maxY = BGE.Math.Max(bounds.maxY, p.y) + end for + return bounds + end function + + ' The canvas bounds of a billboard's exactly projected quad. + private function exactBounds(renderer as BGE.Renderer, sceneObj as BGE.SceneObject) as BGE.OcclusionTestBounds + return m.projectedBounds(renderer, sceneObj.getWorldPoints().toClockwiseOutlineArray()) + end function + + ' Wraps attemptTransformTempBitmap() to count how often it offered a reusable transform. + private sub spyOnTempBitmapReuse(sceneObj as BGE.SceneObject) + spy = sceneObj as dynamic + spy.attemptCount = 0 + spy.reuseCount = 0 + spy.realAttemptTransformTempBitmap = spy.attemptTransformTempBitmap + spy.attemptTransformTempBitmap = function(renderObj as object, drawMode as integer) as object + m.attemptCount++ + result = m.realAttemptTransformTempBitmap(renderObj, drawMode) + if result <> invalid + m.reuseCount++ + end if + return result + end function + end sub + + ' Draws a non-static rectangle 3 times with a small camera move between, returning the spy. + private function drawMovingRectangle(asOccluder as boolean, policy = BGE.OccluderDrawPolicy.fastShrink as BGE.OccluderDrawPolicy) as dynamic + camera = m.newCamera3d(BGE.Math.VectorOps.create(0, 0, 500)) + renderer = m.newRenderer3d(camera) + renderer.occluderDrawPolicy = policy + entity = new BGE.GameEntity(m.game, {name: "panel"}) + entity.position = BGE.Math.VectorOps.create(-100, 100, 0) + entity.updateTransformationMatrix() + drawable = new BGE.DrawableRectangle(entity, 200, 200, {drawMode: BGE.SceneObjectDrawMode.oriented}) + sceneObj = drawable.addToRenderer(renderer) + if asOccluder + renderer.markOccluder(sceneObj) + end if + m.spyOnTempBitmapReuse(sceneObj) + for i = 0 to 2 + camera.position = BGE.Math.VectorOps.create(i * 2, 0, 500) + renderer.setupCameraForFrame() + renderer.render() + end for + return sceneObj + end function + + ' The stale-frame count of a single-face model's face on each frame: rotating for + ' `movingFrames`, then still for `stillFrames`. + private function modelFaceStaleCounts(asOccluder as boolean, policy = BGE.OccluderDrawPolicy.fastShrink as BGE.OccluderDrawPolicy, movingFrames = 3 as integer, stillFrames = 0 as integer, maxStaleFrames = 1 as integer) as integer[] + renderer = m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, 1000))) + renderer.occluderDrawPolicy = policy + entity = new BGE.GameEntity(m.game, {name: "spinner"}) + entity.position = BGE.Math.VectorOps.create(0, 0, 500) + face = BGE.Model3dFaceOps.create([ + BGE.Math.VectorOps.create(-40, -40, 0), + BGE.Math.VectorOps.create(40, -40, 0), + BGE.Math.VectorOps.create(0, 40, 0) + ]) + drawable = new BGE.DrawableModel(entity, new BGE.Model3d([face]), {drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace}) + drawable.maxStaleFrames = maxStaleFrames + sceneObj = drawable.addToRenderer(renderer) as BGE.SceneObjectModel + if asOccluder + renderer.markOccluder(sceneObj) + end if + staleCounts = [] as integer[] + for i = 1 to movingFrames + stillFrames + entity.rotation = BGE.Math.VectorOps.create(0, 0.01 * BGE.Math.Min(i, movingFrames), 0) + entity.updateTransformationMatrix() + renderer.setupCameraForFrame() + renderer.render() + staleCounts.push(sceneObj.getFaceStaleFrameCount(0)) + end for + return staleCounts + end function + + ' A 300x300 occluder wall's claimed and exact canvas bounds on a frame the camera moves, + ' after 3 still frames. + private function movingWallClaim(policy as BGE.OccluderDrawPolicy) as roAssociativeArray + renderer = m.newOcclusionRenderer() + renderer.occluderDrawPolicy = policy + wall = m.newOccluderWall(renderer, 0, 300, 300) + m.renderFrames(renderer, 3) + renderer.camera.position = BGE.Math.VectorOps.create(1, 0, -500) + m.renderFrames(renderer, 1) + return {claimed: m.claimedBounds(renderer, wall), exact: m.exactBounds(renderer, wall)} + end function + + private function boundsMatch(a as BGE.OcclusionTestBounds, b as BGE.OcclusionTestBounds) as boolean + return Abs(a.minX - b.minX) < 0.01 and Abs(a.maxX - b.maxX) < 0.01 and Abs(a.minY - b.minY) < 0.01 and Abs(a.maxY - b.maxY) < 0.01 + end function + + ' A 100x100 oriented image occluder facing the camera, after 2 frames. + private function newFastPathImage(policy as BGE.OccluderDrawPolicy) as roAssociativeArray + renderer = m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, 500))) + renderer.occluderDrawPolicy = policy + entity = new BGE.GameEntity(m.game, {name: "poster", isStatic: true}) + entity.position = BGE.Math.VectorOps.create(-50, 50, 0) + entity.updateTransformationMatrix() + bitmap = CreateObject("roBitmap", {width: 100, height: 100, alphaEnable: true}) + bitmap.Clear(&hFF0000FF) + image = new BGE.Image(entity, CreateObject("roRegion", bitmap, 0, 0, 100, 100), {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.oriented}) + sceneObj = image.addToRenderer(renderer) as BGE.SceneObjectImage + m.renderFrames(renderer, 2) + return {renderer: renderer, image: sceneObj} + end function + + @describe("registering occluders") + + @it("registers a flagged drawable of a static entity as an occluder") + function _() + entity = new BGE.GameEntity(m.game, {name: "wall", isStatic: true}) + drawable = entity.addRectangle("r", 100, 100, {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace}) + sceneObj = drawable.getSceneObjects()[0] + m.assertTrue(sceneObj.isOccluder) + m.assertTrue(m.renderer.hasOccluders()) + end function + + @it("ignores isOccluder on a non-static entity") + function _() + entity = new BGE.GameEntity(m.game, {name: "wall"}) + drawable = entity.addRectangle("r", 100, 100, {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace}) + sceneObj = drawable.getSceneObjects()[0] + m.assertFalse(sceneObj.isOccluder) + m.assertFalse(m.renderer.hasOccluders()) + end function + + @it("does not register an unflagged static drawable") + function _() + entity = new BGE.GameEntity(m.game, {name: "wall", isStatic: true}) + drawable = entity.addRectangle("r", 100, 100, {drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace}) + sceneObj = drawable.getSceneObjects()[0] + m.assertFalse(sceneObj.isOccluder) + m.assertFalse(m.renderer.hasOccluders()) + end function + + @it("registers a static model") + function _() + entity = new BGE.GameEntity(m.game, {name: "cube", isStatic: true}) + drawable = new BGE.DrawableModel(entity, m.cubeModel(), {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.solid}) + entity.addDrawable("model", drawable) + sceneObj = drawable.getSceneObjects()[0] as BGE.SceneObjectModel + m.assertTrue(sceneObj.isOccluder) + m.assertTrue(m.renderer.hasOccluders()) + m.assertEqual(6, sceneObj.occluderPanels.count()) + end function + + @it("rebuilds a model occluder's panels after invalidateGeometry()") + function _() + entity = new BGE.GameEntity(m.game, {name: "cube", isStatic: true}) + drawable = new BGE.DrawableModel(entity, m.cubeModel(), {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.solid}) + entity.addDrawable("model", drawable) + sceneObj = drawable.getSceneObjects()[0] as BGE.SceneObjectModel + m.assertEqual(6, sceneObj.occluderPanels.count()) + ' keep just one side (two triangles) + faces = drawable.model.faces + drawable.model.faces = [faces[0], faces[1]] + drawable.invalidateGeometry() + m.renderer.setupCameraForFrame() + m.renderer.render() + m.assertEqual(1, sceneObj.occluderPanels.count()) + end function + + @it("declines a circle") + function _() + entity = new BGE.GameEntity(m.game, {name: "ball", isStatic: true}) + drawable = entity.addCircle("c", 10, {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.oriented}) + sceneObj = drawable.getSceneObjects()[0] + m.assertFalse(sceneObj.isOccluder) + m.assertFalse(m.renderer.hasOccluders()) + end function + + @it("declines a screen-aligned draw mode") + function _() + entity = new BGE.GameEntity(m.game, {name: "tree", isStatic: true}) + drawable = entity.addRectangle("r", 100, 100, {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.directScaled}) + sceneObj = drawable.getSceneObjects()[0] + m.assertFalse(sceneObj.isOccluder) + m.assertFalse(m.renderer.hasOccluders()) + end function + + @it("markOccluder works on an ownerless scene object") + function _() + drawable = new BGE.DrawableRectangle(invalid, 10, 10, {drawMode: BGE.SceneObjectDrawMode.oriented}) + sceneObj = new BGE.SceneObjectRectangle("wallRect", drawable) + m.renderer.addSceneObject(sceneObj) + m.assertFalse(sceneObj.isOccluder) + m.renderer.markOccluder(sceneObj) + m.assertTrue(sceneObj.isOccluder) + m.assertTrue(m.renderer.hasOccluders()) + end function + + @it("does not auto-register an ownerless flagged drawable") + function _() + drawable = new BGE.DrawableRectangle(invalid, 10, 10, {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.oriented}) + sceneObj = new BGE.SceneObjectRectangle("wallRect", drawable) + m.renderer.addSceneObject(sceneObj) + m.assertFalse(sceneObj.isOccluder) + m.assertFalse(m.renderer.hasOccluders()) + end function + + @it("unregisters on removal") + function _() + entity = new BGE.GameEntity(m.game, {name: "wall", isStatic: true}) + entity.addRectangle("r", 100, 100, {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace}) + m.assertTrue(m.renderer.hasOccluders()) + entity.removeDrawable("r") + m.assertFalse(m.renderer.hasOccluders()) + end function + + @it("stops occluding when isOccluder is cleared at runtime, and resumes when set again") + function _() + renderer = m.newOcclusionRenderer() + wall = m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + m.assertTrue(tree.occludedThisFrame) + wall.drawable.isOccluder = false + m.renderFrames(renderer) + m.assertFalse(wall.isOccluder) + m.assertFalse(renderer.hasOccluders()) + m.assertFalse(tree.occludedThisFrame) + wall.drawable.isOccluder = true + m.renderFrames(renderer) + m.assertTrue(wall.isOccluder) + m.assertTrue(tree.occludedThisFrame) + end function + + @it("registers a drawable flagged isOccluder after it was added") + function _() + entity = new BGE.GameEntity(m.game, {name: "wall", isStatic: true}) + drawable = entity.addRectangle("r", 100, 100, {drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace}) + sceneObj = drawable.getSceneObjects()[0] + drawable.isOccluder = true + m.renderer.setupCameraForFrame() + m.renderer.render() + m.assertTrue(sceneObj.isOccluder) + end function + + @describe("occluder polygons") + + @it("contributes one polygon for a solid wall facing the camera") + function _() + renderer = m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, -500))) + sceneObj = m.newWall(renderer) + polygons = m.renderAndCollect(renderer, sceneObj) + m.assertEqual(1, polygons.count()) + m.assertEqual(4, polygons[0].xs.count()) + m.assertEqual(4, polygons[0].ys.count()) + m.assertTrue(polygons[0].depth > 0, `depth was ${polygons[0].depth}`) + m.assertTrue(polygons[0].area > 0, `area was ${polygons[0].area}`) + end function + + @it("contributes a plain oriented quad facing the camera") + function _() + renderer = m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, 500))) + sceneObj = m.newWall(renderer, {drawMode: BGE.SceneObjectDrawMode.oriented}) + m.assertEqual(1, m.renderAndCollect(renderer, sceneObj).count()) + end function + + @it("contributes nothing in a wireframe mode") + function _() + renderer = m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, -500))) + sceneObj = m.newWall(renderer, {drawMode: BGE.SceneObjectDrawMode.wireFrameDrawBackFace}) + m.assertEqual(0, m.renderAndCollect(renderer, sceneObj).count()) + end function + + @it("contributes nothing when partly faded") + function _() + camera = m.newCamera3d(BGE.Math.VectorOps.create(0, 0, -500)) + camera.drawDistanceFadeFraction = 0.9 + renderer = m.newRenderer3d(camera) + sceneObj = m.newWall(renderer) + polygons = m.renderAndCollect(renderer, sceneObj) + m.assertTrue(sceneObj.distanceFadeAlpha > 0 and sceneObj.distanceFadeAlpha < 1, `fade was ${sceneObj.distanceFadeAlpha}`) + m.assertEqual(0, polygons.count()) + end function + + @it("contributes nothing when drawable alpha is below 255") + function _() + renderer = m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, -500))) + sceneObj = m.newWall(renderer, {alpha: 254}) + m.assertEqual(0, m.renderAndCollect(renderer, sceneObj).count()) + end function + + @it("contributes nothing for an unfilled rectangle") + function _() + renderer = m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, -500))) + sceneObj = m.newWall(renderer) + (sceneObj.drawable as BGE.DrawableRectangle).filled = false + m.assertEqual(0, m.renderAndCollect(renderer, sceneObj).count()) + end function + + @it("contributes nothing for a plain oriented quad facing away") + function _() + renderer = m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, -500))) + sceneObj = m.newWall(renderer, {drawMode: BGE.SceneObjectDrawMode.oriented}) + m.assertEqual(0, m.renderAndCollect(renderer, sceneObj).count()) + end function + + @it("contributes nothing when a corner is behind the camera") + function _() + ' just off the wall, looking along it: its x = -100 corners are behind + renderer = m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, -50), BGE.Math.VectorOps.create(500, 0, -50))) + sceneObj = m.newWall(renderer) + m.assertEqual(0, m.renderAndCollect(renderer, sceneObj).count()) + end function + + @it("contributes nothing while disabled") + function _() + renderer = m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, -500))) + sceneObj = m.newWall(renderer) + sceneObj.drawable.enabled = false + m.assertEqual(0, m.renderAndCollect(renderer, sceneObj).count()) + end function + + @it("contributes nothing under a Camera2d") + function _() + entity = new BGE.GameEntity(m.game, {name: "wall", isStatic: true}) + drawable = entity.addRectangle("r", 100, 100, {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace}) + sceneObj = drawable.getSceneObjects()[0] + m.assertTrue(sceneObj.isOccluder) + m.assertEqual(0, m.renderAndCollect(m.renderer, sceneObj).count()) + end function + + @it("moves a model occluder's panels with the model") + function _() + renderer = m.newOcclusionRenderer() + entity = new BGE.GameEntity(m.game, {name: "cube"}) + entity.position = BGE.Math.VectorOps.create(-50, -50, -50) + drawable = new BGE.DrawableModel(entity, m.cubeModel(100), {drawMode: BGE.SceneObjectDrawMode.solid}) + sceneObj = drawable.addToRenderer(renderer) + renderer.markOccluder(sceneObj) + m.renderFrames(renderer) + before = m.claimedBounds(renderer, sceneObj) + entity.position = BGE.Math.VectorOps.create(0, -50, -50) + m.renderFrames(renderer, 3) + after = m.claimedBounds(renderer, sceneObj) + ' looking down +z, so world +x is to the left on screen + m.assertTrue(after.minX < before.minX - 10, `minX ${before.minX} -> ${after.minX}`) + m.assertTrue(after.maxX < before.maxX - 10, `maxX ${before.maxX} -> ${after.maxX}`) + end function + + @it("contributes a cube model's front-facing panels only") + function _() + camera = m.newCamera3d(BGE.Math.VectorOps.create(0, 0, -500)) + renderer = m.newRenderer3d(camera) + sceneObj = m.newCube(renderer) + m.renderFrames(renderer) + polygons = m.renderAndCollect(renderer, sceneObj) + m.assertEqual(1, polygons.count()) + ' the same faces the draw path drew, two triangles per panel + m.assertEqual(2, sceneObj.getPrimitiveCount()) + + camera.position = BGE.Math.VectorOps.create(-400, 400, -400) + camera.setTarget(BGE.Math.VectorOps.create()) + m.renderAndCollect(renderer, sceneObj) + ' once the camera has stopped, so the panels aren't shrunk + polygons = m.renderAndCollect(renderer, sceneObj) + m.assertEqual(3, polygons.count()) + m.assertEqual(6, sceneObj.getPrimitiveCount()) + for each polygon in polygons + m.assertEqual(4, polygon.xs.count()) + end for + end function + + @it("reuses a transformed temp bitmap for a moving occluder") + function _() + occluder = m.drawMovingRectangle(true) + m.assertTrue(occluder.reuseCount > 0, `reused ${occluder.reuseCount} of ${occluder.attemptCount} attempts`) + end function + + @it("reuses a model face's stale raster for a moving occluder") + function _() + m.assertEqual(1, m.modelFaceStaleCounts(true)[1]) + end function + + @it("claims its exact outline while nothing moves") + function _() + renderer = m.newOcclusionRenderer() + wall = m.newOccluderWall(renderer, 0, 300, 300) + m.renderFrames(renderer, 3) + claimed = m.claimedBounds(renderer, wall) + exact = m.exactBounds(renderer, wall) + m.assertTrue(Abs(claimed.minX - exact.minX) < 0.001 and Abs(claimed.maxX - exact.maxX) < 0.001 and Abs(claimed.minY - exact.minY) < 0.001 and Abs(claimed.maxY - exact.maxY) < 0.001, `claimed ${FormatJson(claimed)} vs exact ${FormatJson(exact)}`) + end function + + @it("shrinks its claim by at least the temp bitmap reuse threshold while the camera moves") + function _() + renderer = m.newOcclusionRenderer() + wall = m.newOccluderWall(renderer, 0, 300, 300) + m.renderFrames(renderer, 3) + renderer.camera.position = BGE.Math.VectorOps.create(1, 0, -500) + m.renderFrames(renderer, 1) + threshold = (wall as dynamic).getTempBitmapThreshold(BGE.SceneObjectDrawMode.orientedDrawBackFace) + polygons = [] as BGE.OccluderPolygon[] + wall.appendOccluderPolygons(renderer, polygons) + m.assertEqual(1, polygons.count(), "the wall should still claim something") + claimed = m.claimedBounds(renderer, wall) + exact = m.exactBounds(renderer, wall) + m.assertTrue(claimed.minX >= exact.minX + threshold and claimed.maxX <= exact.maxX - threshold and claimed.minY >= exact.minY + threshold and claimed.maxY <= exact.maxY - threshold, `claimed ${FormatJson(claimed)} vs exact ${FormatJson(exact)}, threshold ${threshold}`) + end function + + @it("shrinks a model's panels by at least the face drift distance while the camera moves") + function _() + renderer = m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, -400))) + cube = m.newCube(renderer, 300) + m.renderFrames(renderer, 3) + renderer.camera.position = BGE.Math.VectorOps.create(1, 0, -400) + m.renderFrames(renderer, 1) + polygons = [] as BGE.OccluderPolygon[] + cube.appendOccluderPolygons(renderer, polygons) + m.assertEqual(1, polygons.count(), "the front panel should still claim something") + claimed = m.claimedBounds(renderer, cube) + exact = m.projectedBounds(renderer, [ + BGE.Math.VectorOps.create(-150, -150, -150), + BGE.Math.VectorOps.create(150, -150, -150), + BGE.Math.VectorOps.create(150, 150, -150), + BGE.Math.VectorOps.create(-150, 150, -150) + ]) + drift = cube.drawable.maxFaceDriftDistance + m.assertTrue(claimed.minX >= exact.minX + drift and claimed.maxX <= exact.maxX - drift and claimed.minY >= exact.minY + drift and claimed.maxY <= exact.maxY - drift, `claimed ${FormatJson(claimed)} vs exact ${FormatJson(exact)}`) + end function + + @it("leaves out a model panel with a corner inside it while the camera moves") + function _() + renderer = m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, -400))) + entity = new BGE.GameEntity(m.game, {name: "fan", isStatic: true}) + entity.position = BGE.Math.VectorOps.create(-150, -150, 0) + entity.updateTransformationMatrix() + ' four triangles around the square's centre, which a skipped tiny face could sit at + corners = [[0, 0], [300, 0], [300, 300], [0, 300]] + faces = [] + for i = 0 to 3 + a = corners[i] + b = corners[(i + 1) mod 4] + faces.push(BGE.Model3dFaceOps.create([ + BGE.Math.VectorOps.create(a[0], a[1], 0), + BGE.Math.VectorOps.create(b[0], b[1], 0), + BGE.Math.VectorOps.create(150, 150, 0) + ])) + end for + drawable = new BGE.DrawableModel(entity, new BGE.Model3d(faces), {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.solidDrawBackFace}) + sceneObj = drawable.addToRenderer(renderer) + m.renderFrames(renderer, 3) + polygons = [] as BGE.OccluderPolygon[] + sceneObj.appendOccluderPolygons(renderer, polygons) + m.assertEqual(1, polygons.count(), "a still model claims the panel") + renderer.camera.position = BGE.Math.VectorOps.create(1, 0, -400) + m.renderFrames(renderer, 1) + polygons = [] as BGE.OccluderPolygon[] + sceneObj.appendOccluderPolygons(renderer, polygons) + m.assertEqual(0, polygons.count()) + end function + + @it("claims a rotated or mirrored fast-path image's own quad, inset 1px") + function _() + for each scaleX in [1, -1] + renderer = m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, 500))) + entity = new BGE.GameEntity(m.game, {name: "poster", isStatic: true}) + entity.position = BGE.Math.VectorOps.create(-50, 50, 0) + entity.rotation = BGE.Math.VectorOps.create(0, 0, 0.4) + entity.updateTransformationMatrix() + bitmap = CreateObject("roBitmap", {width: 160, height: 100, alphaEnable: true}) + bitmap.Clear(&hFF0000FF) + image = new BGE.Image(entity, CreateObject("roRegion", bitmap, 0, 0, 160, 100), {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace}) + image.scale = BGE.Math.VectorOps.create(scaleX, 1, 1) + sceneObj = image.addToRenderer(renderer) as BGE.SceneObjectImage + m.renderFrames(renderer, 2) + m.assertTrue(sceneObj.usedTransformedFastDraw(), `scale ${scaleX}: the occluder should draw through the fast path`) + polygons = [] as BGE.OccluderPolygon[] + sceneObj.appendOccluderPolygons(renderer, polygons) + m.assertEqual(1, polygons.count()) + exact = [] as BGE.Math.Vector[] + for each corner in sceneObj.getWorldPoints().toArray() + exact.push(renderer.camera.worldPointToCanvasPoint(corner)) + end for + ' each claimed corner sits about 1px in from an exact corner (sqrt 2 along the diagonal, plus rounding) + for i = 0 to 3 + nearest = 1e9 + for each point in exact + nearest = BGE.Math.Min(nearest, Sqr((polygons[0].xs[i] - point.x) ^ 2 + (polygons[0].ys[i] - point.y) ^ 2)) + end for + m.assertTrue(nearest > 0.5 and nearest < 3, `scale ${scaleX}: corner ${i} is ${nearest}px from the quad's nearest corner`) + end for + end for + end function + + @it("claims the rectangle the image fast path paints, inset 1px, and takes the fast path") + function _() + renderer = m.newRenderer3d(m.newCamera3d(BGE.Math.VectorOps.create(0, 0, 500))) + entity = new BGE.GameEntity(m.game, {name: "poster", isStatic: true}) + entity.position = BGE.Math.VectorOps.create(-50, 50, 0) + entity.updateTransformationMatrix() + bitmap = CreateObject("roBitmap", {width: 100, height: 100, alphaEnable: true}) + bitmap.Clear(&hFF0000FF) + image = new BGE.Image(entity, CreateObject("roRegion", bitmap, 0, 0, 100, 100), {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.oriented}) + sceneObj = image.addToRenderer(renderer) as BGE.SceneObjectImage + m.assertTrue(sceneObj.isOccluder) + m.renderFrames(renderer, 2) + m.assertTrue(sceneObj.usedTransformedFastDraw(), "the occluder should draw through the fast path") + claimed = m.claimedBounds(renderer, sceneObj) + exact = m.exactBounds(renderer, sceneObj) + m.assertTrue(Abs(claimed.minX - exact.minX - 1) < 0.01 and Abs(claimed.maxX - exact.maxX + 1) < 0.01 and Abs(claimed.minY - exact.minY - 1) < 0.01 and Abs(claimed.maxY - exact.maxY + 1) < 0.01, `claimed ${FormatJson(claimed)} vs exact ${FormatJson(exact)}`) + end function + + @describe("occluder draw policy") + + @it("exact: never reuses a temp bitmap for a moving occluder") + function _() + occluder = m.drawMovingRectangle(true, BGE.OccluderDrawPolicy.exact) + m.assertTrue(occluder.attemptCount > 0) + m.assertEqual(0, occluder.reuseCount) + end function + + @it("exact: never reuses a model face's raster") + function _() + m.assertEqual([0, 0, 0, 0], m.modelFaceStaleCounts(true, BGE.OccluderDrawPolicy.exact, 3, 1, 3)) + end function + + @it("exact: never takes the image fast path, and claims the exact quad") + function _() + result = m.newFastPathImage(BGE.OccluderDrawPolicy.exact) + image = result.image as BGE.SceneObjectImage + m.assertFalse(image.usedTransformedFastDraw()) + claimed = m.claimedBounds(result.renderer, image) + exact = m.exactBounds(result.renderer, image) + m.assertTrue(m.boundsMatch(claimed, exact), `claimed ${FormatJson(claimed)} vs exact ${FormatJson(exact)}`) + end function + + @it("exact: claims its exact outline while the camera moves") + function _() + result = m.movingWallClaim(BGE.OccluderDrawPolicy.exact) + m.assertTrue(m.boundsMatch(result.claimed, result.exact), `claimed ${FormatJson(result.claimed)} vs exact ${FormatJson(result.exact)}`) + end function + + @it("fastShrink: shrinks its claim while the camera moves") + function _() + result = m.movingWallClaim(BGE.OccluderDrawPolicy.fastShrink) + m.assertTrue(result.claimed.minX > result.exact.minX + 1 and result.claimed.maxX < result.exact.maxX - 1, `claimed ${FormatJson(result.claimed)} vs exact ${FormatJson(result.exact)}`) + end function + + @it("fastShrink: an occluder model redraws its faces fresh on a still frame") + function _() + m.assertEqual([0, 1, 0], m.modelFaceStaleCounts(true, BGE.OccluderDrawPolicy.fastShrink, 2, 1, 3)) + end function + + @it("a model that isn't an occluder still reuses face rasters on a still frame") + function _() + m.assertEqual([0, 1, 2], m.modelFaceStaleCounts(false, BGE.OccluderDrawPolicy.fastShrink, 2, 1, 3)) + end function + + @it("fastExactClaim: reuses a temp bitmap for a moving occluder") + function _() + occluder = m.drawMovingRectangle(true, BGE.OccluderDrawPolicy.fastExactClaim) + m.assertTrue(occluder.reuseCount > 0, `reused ${occluder.reuseCount} of ${occluder.attemptCount} attempts`) + end function + + @it("fastExactClaim: claims its exact outline while the camera moves") + function _() + result = m.movingWallClaim(BGE.OccluderDrawPolicy.fastExactClaim) + m.assertTrue(m.boundsMatch(result.claimed, result.exact), `claimed ${FormatJson(result.claimed)} vs exact ${FormatJson(result.exact)}`) + end function + + @it("fastExactClaim: takes the image fast path and claims the exact quad") + function _() + result = m.newFastPathImage(BGE.OccluderDrawPolicy.fastExactClaim) + image = result.image as BGE.SceneObjectImage + m.assertTrue(image.usedTransformedFastDraw()) + claimed = m.claimedBounds(result.renderer, image) + exact = m.exactBounds(result.renderer, image) + m.assertTrue(m.boundsMatch(claimed, exact), `claimed ${FormatJson(claimed)} vs exact ${FormatJson(exact)}`) + end function + + @it("fastExactClaim: an occluder model reuses face rasters on a still frame") + function _() + m.assertEqual([0, 1, 2], m.modelFaceStaleCounts(true, BGE.OccluderDrawPolicy.fastExactClaim, 2, 1, 3)) + end function + + @it("applies a policy changed after the occluders registered from the next render") + function _() + renderer = m.newOcclusionRenderer() + wall = m.newOccluderWall(renderer, 0, 300, 300) + m.renderFrames(renderer, 3) + renderer.occluderDrawPolicy = BGE.OccluderDrawPolicy.exact + renderer.camera.position = BGE.Math.VectorOps.create(1, 0, -500) + m.renderFrames(renderer, 1) + m.assertEqual(BGE.OccluderDrawPolicy.exact, wall.occluderDrawPolicy) + claimed = m.claimedBounds(renderer, wall) + exact = m.exactBounds(renderer, wall) + m.assertTrue(m.boundsMatch(claimed, exact), `claimed ${FormatJson(claimed)} vs exact ${FormatJson(exact)}`) + end function + + @describe("occlusion culling") + + @it("treats an occlusionBandCount below 1 as one band") + function _() + renderer = m.newOcclusionRenderer() + renderer.occlusionBandCount = 0 + m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + ' one full-height band: the shorter wall can't cover it, so the tree still draws + m.assertEqual(1, (renderer as dynamic).occlusionMap.getBandCount()) + m.assertFalse(tree.occludedThisFrame) + end function + + @it("occludes an object fully behind a wall") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility()) + m.assertTrue(tree.occludedThisFrame) + m.assertEqual(0, m.performDrawCount(tree)) + m.assertEqual(1, renderer.getOccludedCountLastFrame()) + m.assertNotInvalid(tree.occlusionTestBounds) + end function + + @it("does not occlude an object beside the wall") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 400, 200) + m.renderFrames(renderer) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility()) + m.assertTrue(m.performDrawCount(tree) > 0) + m.assertEqual(0, renderer.getOccludedCountLastFrame()) + end function + + @it("does not occlude an object in front of the wall") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, -100) + m.renderFrames(renderer) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility()) + end function + + @it("hides an object behind two touching walls") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, -75, 150, 300) + m.newOccluderWall(renderer, 75, 150, 300) + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility()) + end function + + @it("does not hide an object just inside a wall's edge while the camera moves") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + renderer.setupCameraForFrame() + ' the tree's right edge lands 6px inside the wall's right edge on the canvas + cam = renderer.camera + origin = cam.worldPointToCanvasPoint(BGE.Math.VectorOps.create(0, 0, 0)) + wallScale = cam.worldPointToCanvasPoint(BGE.Math.VectorOps.create(1, 0, 0)).x - origin.x + treeScale = cam.worldPointToCanvasPoint(BGE.Math.VectorOps.create(1, 0, 200)).x - cam.worldPointToCanvasPoint(BGE.Math.VectorOps.create(0, 0, 200)).x + treeRight = (150 * wallScale - 6) / treeScale + tree = m.newTree(renderer, treeRight - 10, 200) + m.renderFrames(renderer, 3) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility(), "the still wall should hide it") + for i = 1 to 2 + cam.position = BGE.Math.VectorOps.create(i * 0.2, 0, -500) + m.renderFrames(renderer, 1) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility(), `frame ${i} of the move`) + end for + m.renderFrames(renderer, 1) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility(), "hidden again once the camera stops") + end function + + @it("never occludes an object with a bounding point behind the camera") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + ' turned to run along z, from behind the wall to behind the camera + entity = new BGE.GameEntity(m.game, {name: "log"}) + entity.position = BGE.Math.VectorOps.create(5, 10, 200) + entity.rotation = BGE.Math.VectorOps.create(0, -BGE.Math.HALF_PI, 0) + entity.updateTransformationMatrix() + drawable = new BGE.DrawableRectangle(entity, 900, 20, {drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace}) + longTree = drawable.addToRenderer(renderer) + m.renderFrames(renderer) + m.assertTrue(longTree.negDistanceFromCamera < 0, "its centre should be in front of the camera") + behindCamera = 0 + for each point in longTree.getOcclusionTestPoints(renderer.camera) + if invalid = renderer.camera.worldPointToCanvasPoint(point) + behindCamera++ + end if + end for + m.assertTrue(behindCamera > 0, "a bounding point should be behind the camera") + m.assertFalse(longTree.occludedThisFrame) + m.assertNotEqual(BGE.VisibilityState.occluded, longTree.getVisibility()) + end function + + @it("shows a hidden object again the frame its occluder is disabled") + function _() + renderer = m.newOcclusionRenderer() + wall = m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility()) + wall.drawable.enabled = false + m.renderFrames(renderer, 1) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility()) + m.assertEqual(1, m.performDrawCount(tree)) + end function + + @it("shows a hidden object again the frame its occluder is removed") + function _() + renderer = m.newOcclusionRenderer() + wall = m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility()) + wall.drawable.removeFromRenderer(renderer) + m.renderFrames(renderer, 1) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility()) + m.assertEqual(1, m.performDrawCount(tree)) + end function + + @it("does nothing when occlusionCullingEnabled is false") + function _() + renderer = m.newOcclusionRenderer() + renderer.occlusionCullingEnabled = false + m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility()) + m.assertInvalid(renderer.getOcclusionMap()) + end function + + @it("stops occluding the frame occlusionCullingEnabled is turned off") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility()) + renderer.occlusionCullingEnabled = false + m.renderFrames(renderer, 1) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility()) + m.assertFalse(tree.occludedThisFrame) + m.assertInvalid(tree.occlusionTestBounds) + renderer.occlusionCullingEnabled = true + m.renderFrames(renderer, 1) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility()) + end function + + @it("does nothing under a Camera2d") + function _() + entity = new BGE.GameEntity(m.game, {name: "wall", isStatic: true}) + entity.addRectangle("r", 300, 300, {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace}) + m.assertTrue(m.renderer.hasOccluders()) + m.renderFrames(m.renderer) + m.assertInvalid(m.renderer.getOcclusionMap()) + m.assertEqual(0, m.renderer.getOccludedCountLastFrame()) + end function + + @it("does no occlusion work with no occluders registered") + function _() + renderer = m.newOcclusionRenderer() + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + m.assertInvalid(renderer.getOcclusionMap()) + m.assertEqual(0, renderer.getOcclusionMapBuildCount()) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility()) + end function + + @it("reuses the map while nothing moves") + function _() + camera = m.newCamera3d(BGE.Math.VectorOps.create(0, 0, -500)) + renderer = m.newRenderer3d(camera) + m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer, 3) + builds = renderer.getOcclusionMapBuildCount() + m.assertTrue(builds > 0) + m.renderFrames(renderer, 2) + m.assertEqual(builds, renderer.getOcclusionMapBuildCount()) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility()) + camera.position = BGE.Math.VectorOps.create(1, 0, -500) + m.renderFrames(renderer, 1) + m.assertEqual(builds + 1, renderer.getOcclusionMapBuildCount()) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility()) + end function + + @it("keeps only the largest occluders when over maxOccluders") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, -300, 300, 300) + m.newOccluderWall(renderer, 0, 60, 60) + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility(), "the small wall alone should hide it") + renderer.maxOccluders = 1 + m.renderFrames(renderer, 1) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility()) + end function + + @it("hides objects behind a static model occluder") + function _() + renderer = m.newOcclusionRenderer() + m.newCube(renderer, 300) + tree = m.newTree(renderer, 0, 300) + m.renderFrames(renderer) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility()) + m.assertEqual(0, m.performDrawCount(tree)) + end function + + @it("is not reported occluded when an object moves out from behind the wall") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility()) + owner = tree.drawable.owner + owner.position = BGE.Math.VectorOps.create(390, 10, 200) + m.renderFrames(renderer, 1) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility()) + m.assertEqual(1, m.performDrawCount(tree)) + end function + + @it("never occludes a directToCamera billboard") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + entity = new BGE.GameEntity(m.game, {name: "sprite"}) + entity.position = BGE.Math.VectorOps.create(0, 0, 200) + entity.updateTransformationMatrix() + drawable = new BGE.DrawableRectangle(entity, 2, 2, {drawMode: BGE.SceneObjectDrawMode.directToCamera}) + sprite = drawable.addToRenderer(renderer) + m.renderFrames(renderer) + m.assertFalse(sprite.occludedThisFrame) + end function + + @it("tests a directScaled billboard by its camera-facing quad") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + small = m.newEdgeOnSprite(renderer, 20, BGE.Math.HALF_PI) + ' wider on screen than the wall, though its own corners, edge-on, would sit behind it + wideA = m.newEdgeOnSprite(renderer, 400, BGE.Math.HALF_PI) + wideB = m.newEdgeOnSprite(renderer, 400, -BGE.Math.HALF_PI) + m.renderFrames(renderer) + m.assertTrue(small.occludedThisFrame) + m.assertFalse(wideA.occludedThisFrame) + m.assertFalse(wideB.occludedThisFrame) + m.assertEqual(BGE.VisibilityState.visible, wideA.getVisibility()) + m.assertEqual(BGE.VisibilityState.visible, wideB.getVisibility()) + end function + + @it("shows a hidden object again the frame its occluder stops being filled") + function _() + renderer = m.newOcclusionRenderer() + wall = m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility()) + (wall.drawable as BGE.DrawableRectangle).filled = false + m.renderFrames(renderer, 1) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility()) + m.assertEqual(1, m.performDrawCount(tree)) + end function + + @it("redraws a revealed object's cached bitmap when the camera moved while it was hidden") + function _() + camera = m.newCamera3d(BGE.Math.VectorOps.create(0, 0, -500)) + renderer = m.newRenderer3d(camera) + wall = m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, 200) + spy = tree as dynamic + spy.createTempBitmapCount = 0 + spy.realCreateTempBitmap = spy.createTempBitmap + spy.createTempBitmap = function(canvasBounds as object, rendererObj as object, drawMode as integer, allowFastDraw = false as boolean) as object + m.createTempBitmapCount++ + return m.realCreateTempBitmap(canvasBounds, rendererObj, drawMode, allowFastDraw) + end function + wall.drawable.enabled = false + m.renderFrames(renderer) + m.assertTrue(spy.createTempBitmapCount > 0, "the tree should have cached its fill") + wall.drawable.enabled = true + m.renderFrames(renderer) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility()) + camera.position = BGE.Math.VectorOps.create(10, 5, -500) + m.renderFrames(renderer) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility()) + spy.createTempBitmapCount = 0 + wall.drawable.enabled = false + m.renderFrames(renderer, 1) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility()) + m.assertEqual(1, spy.createTempBitmapCount) + end function + + @it("never occludes text") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + entity = new BGE.GameEntity(m.game, {name: "sign"}) + entity.position = BGE.Math.VectorOps.create(0, 0, 200) + entity.updateTransformationMatrix() + textDrawable = new BGE.DrawableText(entity, "hi", m.game.getFont("default"), {drawMode: BGE.SceneObjectDrawMode.orientedDrawBackFace}) + sign = textDrawable.addToRenderer(renderer) + m.renderFrames(renderer) + m.assertFalse(sign.canBeOccluded()) + m.assertEqual(0, renderer.getOcclusionTestsLastFrame()) + m.assertFalse(sign.occludedThisFrame) + m.assertNotEqual(BGE.VisibilityState.occluded, sign.getVisibility()) + end function + + @it("reports a latched off-camera object as off camera, not occluded") + function _() + renderer = m.newOcclusionRenderer() + wall = m.newOccluderWall(renderer, 0, 300, 300) + ' behind the wall, but past the draw distance + tree = m.newTree(renderer, 0, 5000) + wall.drawable.enabled = false + m.renderFrames(renderer) + ' the cull is only judged once something moves + renderer.camera.position = BGE.Math.VectorOps.create(1, 0, -500) + m.renderFrames(renderer) + m.assertTrue(tree.isCulled(), "the tree should have latched as culled") + wall.drawable.enabled = true + m.renderFrames(renderer) + m.assertTrue(tree.isCulled(), "occlusion shouldn't release the cull latch") + m.assertFalse(tree.occludedThisFrame) + m.assertEqual(BGE.VisibilityState.offCamera, tree.getVisibility()) + end function + + @it("does not warn that a static model occluder can't join the static geometry tree") + function _() + entity = new BGE.GameEntity(m.game, {name: "cube", isStatic: true}) + drawable = new BGE.DrawableModel(entity, m.cubeModel(), {isOccluder: true, drawMode: BGE.SceneObjectDrawMode.solid}) + entity.addDrawable("model", drawable) + m.assertTrue(drawable.getSceneObjects()[0].isOccluder) + for each entry in m.game.getLogHistory() + m.assertNotEqual(BGE.Debug.LogLevel.warning, entry.level, `unexpected warning: ${entry.message}`) + end for + end function + + @describe("occlusion test cost") + + ' Renders 2 still frames, then nudges the camera and renders 1 more, so that last frame + ' rebuilds the map and re-judges every object. + private sub renderThenNudgeCamera(renderer as BGE.Renderer) + m.renderFrames(renderer) + renderer.camera.position = BGE.Math.VectorOps.create(1, 0, -500) + m.renderFrames(renderer, 1) + end sub + + @it("fully tests an object behind a wall") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, 200) + m.renderThenNudgeCamera(renderer) + m.assertEqual(1, renderer.getOcclusionTestsLastFrame()) + m.assertEqual(BGE.VisibilityState.occluded, tree.getVisibility()) + end function + + @it("doesn't fully test an object nearer than every occluder") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, -100) + m.renderThenNudgeCamera(renderer) + m.assertEqual(0, renderer.getOcclusionTestsLastFrame()) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility()) + end function + + @it("doesn't fully test an object off to the side of every occluder") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 300, 200) + m.renderThenNudgeCamera(renderer) + m.assertEqual(0, renderer.getOcclusionTestsLastFrame()) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility()) + end function + + @it("doesn't fully test an object behind the gap between two occluders") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, -200, 100, 300) + m.newOccluderWall(renderer, 200, 100, 300) + tree = m.newTree(renderer, 0, 200) + m.renderThenNudgeCamera(renderer) + m.assertEqual(0, renderer.getOcclusionTestsLastFrame()) + m.assertEqual(BGE.VisibilityState.visible, tree.getVisibility()) + end function + + @it("tests an oriented billboard by the quad it draws") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + expected = m.exactBounds(renderer, tree) + margin = renderer.occlusionMargin + m.assertEqual(tree.occlusionTestBounds.minX, expected.minX - margin) + m.assertEqual(tree.occlusionTestBounds.maxX, expected.maxX + margin) + m.assertEqual(tree.occlusionTestBounds.minY, expected.minY - margin) + m.assertEqual(tree.occlusionTestBounds.maxY, expected.maxY + margin) + end function + + @it("reports how long the occlusion pass took") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + m.assertTrue(renderer.getOcclusionMsLastFrame() >= 0) + end function + + @describe("debug overlay") + + @it("draws the occlusion overlay only when enabled") + function _() + renderer = m.newOcclusionRenderer() + m.newOccluderWall(renderer, 0, 300, 300) + tree = m.newTree(renderer, 0, 200) + m.renderFrames(renderer) + m.assertTrue(tree.occludedThisFrame) + renderer.resetDrawCallCounter() + m.renderFrames(renderer, 1) + plainCalls = renderer.getDrawCallsLastFrame() + renderer.showOcclusionDebug = true + renderer.resetDrawCallCounter() + m.renderFrames(renderer, 1) + m.assertTrue(renderer.getDrawCallsLastFrame() > plainCalls, `${renderer.getDrawCallsLastFrame()} vs ${plainCalls}`) + end function + + @it("defaults to off, and Game toggles it on the game canvas") + function _() + m.assertFalse(m.renderer.showOcclusionDebug) + m.game.debugDrawOcclusion(true) + m.assertTrue(m.renderer.showOcclusionDebug) + m.game.debugDrawOcclusion(false) + m.assertFalse(m.renderer.showOcclusionDebug) + end function + + @describe("settings") + + @it("has the documented defaults") + function _() + m.assertTrue(m.renderer.occlusionCullingEnabled) + m.assertEqual(45, m.renderer.occlusionBandCount) + m.assertEqual(32, m.renderer.maxOccluders) + m.assertEqual(2.0, m.renderer.occlusionMargin) + m.assertEqual(BGE.OccluderDrawPolicy.fastShrink, m.renderer.occluderDrawPolicy) + end function + + end class + +end namespace diff --git a/src/source/engine/renderer/cameras/Camera3d.bs b/src/source/engine/renderer/cameras/Camera3d.bs index e8548c92..1836de24 100644 --- a/src/source/engine/renderer/cameras/Camera3d.bs +++ b/src/source/engine/renderer/cameras/Camera3d.bs @@ -146,6 +146,13 @@ namespace BGE ' own dirty check or the frustum/view-matrix/plane caches would all go stale. See ' issue #53's roll design. private lastRollDegrees as float = 0 + ' getLevelUpVector()'s last result, and the orientation it was for + private levelUpCache as BGE.Math.Vector = invalid + private levelUpCacheOrientation as BGE.Math.Vector = invalid + ' projectPointsWithDepth()'s focal length, and the frame width and FOV it was for + private projectionNear as float = 0 + private projectionNearFrameWidth as float = -1 + private projectionNearFieldOfView as float = -1 protected frustumNormals as CameraFrustumNormals = new CameraFrustumNormals() @@ -285,6 +292,55 @@ namespace BGE end function + ' Projects many points at once, giving the same canvas point as worldPointToCanvasPoint() + ' and the same depth as distanceFromCameraFront() for each, but faster. + ' + ' @param {BGE.Math.Vector[]} points world points + ' @param {float[]} out cleared, then filled with x, y, depth for each point in turn + ' @return {boolean} false, leaving `out` incomplete, as soon as a point won't project + function projectPointsWithDepth(points as BGE.Math.Vector[], out as float[]) as boolean + out.Clear() + if invalid = m.worldToCamera + m.computeWorldToCameraMatrix() + end if + ' the same arithmetic, in the same order, as multVecMatrix(), worldPointToCanvasPoint() + ' and distanceFromPlane(), so the results match exactly + mat = m.worldToCamera + m0 = mat[0] + m1 = mat[1] + m2 = mat[2] + m3 = mat[3] + if m.frameSize.x <> m.projectionNearFrameWidth or m.fieldOfViewDegrees <> m.projectionNearFieldOfView + m.projectionNear = (m.frameSize.x / 2) / tan(BGE.Math.DegreesToRadians(m.fieldOfViewDegrees) / 2) + m.projectionNearFrameWidth = m.frameSize.x + m.projectionNearFieldOfView = m.fieldOfViewDegrees + end if + near = m.projectionNear + canvasWidth = m.frameSize.x + canvasHeight = m.frameSize.y + nearNormal = m.frustumNormals.near + convergence = m.frustrumConvergence + for each point in points + srcX = point.x + srcY = point.y + srcZ = point.z <> invalid ? point.z : 0 + w = srcX * m0[3] + srcY * m1[3] + srcZ * m2[3] + m3[3] + cz = (srcX * m0[2] + srcY * m1[2] + srcZ * m2[2] + m3[2]) / w + if cz >= 0 + return false + end if + cx = (srcX * m0[0] + srcY * m1[0] + srcZ * m2[0] + m3[0]) / w + cy = (srcX * m0[1] + srcY * m1[1] + srcZ * m2[1] + m3[1]) / w + ndcX = (near * cx / -cz + canvasWidth * 0.5) / canvasWidth + ndcY = (near * cy / -cz + canvasHeight * 0.5) / canvasHeight + out.push(fix(ndcX * canvasWidth)) + out.push(cint((1 - ndcY) * canvasHeight)) + out.push((srcX - convergence.x) * nearNormal.x + (srcY - convergence.y) * nearNormal.y + (srcZ - convergence.z) * nearNormal.z) + end for + return true + end function + + override sub useDefaultCameraTarget() m.setTarget(BGE.Math.VectorOps.create()) end sub @@ -524,6 +580,18 @@ namespace BGE ' ' @return {BGE.Math.Vector} function getLevelUpVector() as BGE.Math.Vector + ' cached by orientation, which callers may replace or change in place + o = m.orientation + cached = m.levelUpCache + if invalid <> cached and o.x = m.levelUpCacheOrientation.x and o.y = m.levelUpCacheOrientation.y and o.z = m.levelUpCacheOrientation.z + return BGE.Math.VectorOps.copy(cached) + end if + m.levelUpCacheOrientation = BGE.Math.VectorOps.copy(o) + m.levelUpCache = m.computeLevelUpVector() + return BGE.Math.VectorOps.copy(m.levelUpCache) + end function + + private function computeLevelUpVector() as BGE.Math.Vector forward = BGE.Math.VectorOps.copy(m.orientation) BGE.Math.VectorOps.normalize(forward) diff --git a/src/source/engine/renderer/cameras/Camera3d.spec.bs b/src/source/engine/renderer/cameras/Camera3d.spec.bs index b8751557..aaa24414 100644 --- a/src/source/engine/renderer/cameras/Camera3d.spec.bs +++ b/src/source/engine/renderer/cameras/Camera3d.spec.bs @@ -25,6 +25,63 @@ namespace tests m.assertTrue(abs(expected - actual) < 0.001, `expected ${expected} but got ${actual}`) end sub + @describe("projectPointsWithDepth") + + @it("matches worldPointToCanvasPoint and distanceFromCameraFront exactly") + function _() + m.camera.setFrameSize(1280, 720) + m.camera.position = BGE.Math.VectorOps.create(30, 50, 120) + m.camera.setTarget(BGE.Math.VectorOps.create(-40, 10, -300)) + m.camera.rollDegrees = 12 + m.camera.checkMovement() + m.camera.computeWorldToCameraMatrix() + points = [ + BGE.Math.VectorOps.create(0, 0, 0), + BGE.Math.VectorOps.create(-123.5, 77.25, -410), + BGE.Math.VectorOps.create(250, -60, -900) + ] + out = [] as float[] + m.assertTrue(m.camera.projectPointsWithDepth(points, out)) + m.assertEqual(out.count(), 9) + for i = 0 to points.count() - 1 + expected = m.camera.worldPointToCanvasPoint(points[i]) + m.assertEqual(out[i * 3], expected.x) + m.assertEqual(out[i * 3 + 1], expected.y) + m.assertEqual(out[i * 3 + 2], m.camera.distanceFromCameraFront(points[i])) + end for + end function + + @it("fails when a point is behind the camera") + function _() + m.lookDownNegativeZ() + m.camera.checkMovement() + m.camera.computeWorldToCameraMatrix() + out = [] as float[] + m.assertFalse(m.camera.projectPointsWithDepth([BGE.Math.VectorOps.create(0, 0, -10), BGE.Math.VectorOps.create(0, 0, 10)], out)) + end function + + @describe("getLevelUpVector") + + @it("follows an orientation changed in place") + function _() + m.lookDownNegativeZ() + first = m.camera.getLevelUpVector() + m.assertTrue(abs(first.y - 1) < 0.0001) + m.camera.orientation.y = -1 + m.camera.orientation.z = -1 + second = m.camera.getLevelUpVector() + m.assertTrue(abs(second.y - Sqr(0.5)) < 0.0001, `got y ${second.y}`) + m.assertTrue(abs(second.z + Sqr(0.5)) < 0.0001, `got z ${second.z}`) + end function + + @it("isn't changed by a caller changing the vector it returned") + function _() + m.lookDownNegativeZ() + first = m.camera.getLevelUpVector() + first.y = 99 + m.assertTrue(abs(m.camera.getLevelUpVector().y - 1) < 0.0001) + end function + @describe("setTarget") @it("points orientation at the target as a normalized direction vector") diff --git a/src/source/engine/renderer/occlusion/ConvexInset.bs b/src/source/engine/renderer/occlusion/ConvexInset.bs new file mode 100644 index 00000000..7a567d2f --- /dev/null +++ b/src/source/engine/renderer/occlusion/ConvexInset.bs @@ -0,0 +1,121 @@ +import "OcclusionTypes.bs" + +namespace BGE.Occlusion + + ' Shrinks a convex canvas-space polygon by moving every edge `distance` px inward. + ' + ' @param {float[]} xs vertex x coordinates, in order around the polygon (either winding) + ' @param {float[]} ys vertex y coordinates + ' @param {float} distance how far to move each edge in; 0 or less returns the polygon as is + ' @return {BGE.CanvasPolygon} the shrunk polygon, or invalid if it has fewer than 3 distinct corners or the inset would collapse it + function insetConvexPolygon(xs as float[], ys as float[], distance as float) as BGE.CanvasPolygon + corners = dropFlatCorners(xs, ys) + if invalid = corners + return invalid + end if + px = corners.xs + py = corners.ys + count = px.count() + if distance <= 0 + return corners + end if + ' +1 when the interior is to the left of each edge (counter-clockwise with y up) + windingSign = 1.0 + if getSignedDoubleArea(px, py) < 0 + windingSign = -1.0 + end if + + ' each edge i runs from corner i to corner i + 1; its offset line passes through (ox, oy) + ex = [] as float[] + ey = [] as float[] + ox = [] as float[] + oy = [] as float[] + for i = 0 to count - 1 + j = (i + 1) mod count + dx = px[j] - px[i] + dy = py[j] - py[i] + length = Sqr(dx * dx + dy * dy) + ex.push(dx) + ey.push(dy) + ox.push(px[i] - windingSign * dy / length * distance) + oy.push(py[i] + windingSign * dx / length * distance) + end for + + ' corner i is where the offset lines of edges i - 1 and i meet + insetXs = [] as float[] + insetYs = [] as float[] + for i = 0 to count - 1 + k = (i + count - 1) mod count + denominator = ex[k] * ey[i] - ey[k] * ex[i] + t = ((ox[i] - ox[k]) * ey[i] - (oy[i] - oy[k]) * ex[i]) / denominator + insetXs.push(ox[k] + t * ex[k]) + insetYs.push(oy[k] + t * ey[k]) + end for + + ' an edge that now points backwards has been squeezed out, so the polygon has collapsed + for i = 0 to count - 1 + j = (i + 1) mod count + if (insetXs[j] - insetXs[i]) * ex[i] + (insetYs[j] - insetYs[i]) * ey[i] <= 0 + return invalid + end if + end for + return {xs: insetXs, ys: insetYs} + end function + + ' The area of a simple canvas-space polygon, in either winding. + ' + ' @param {float[]} xs + ' @param {float[]} ys + ' @return {float} + function getPolygonArea(xs as float[], ys as float[]) as float + return Abs(getSignedDoubleArea(xs, ys)) / 2 + end function + + ' Twice the polygon's signed area (shoelace), positive when counter-clockwise with y up. + function getSignedDoubleArea(xs as float[], ys as float[]) as float + twiceArea = 0.0 + count = xs.count() + for i = 0 to count - 1 + j = (i + 1) mod count + twiceArea += xs[i] * ys[j] - xs[j] * ys[i] + end for + return twiceArea + end function + + ' A copy of the polygon without repeated or (nearly) collinear corners, which have no + ' edge direction to offset. Dropping one only trims a sliver off a convex polygon. + ' invalid when fewer than 3 corners remain. + function dropFlatCorners(xs as float[], ys as float[]) as BGE.CanvasPolygon + px = [] as float[] + py = [] as float[] + for i = 0 to xs.count() - 1 + px.push(xs[i] * 1.0) + py.push(ys[i] * 1.0) + end for + dropped = true + while dropped and px.count() >= 3 + dropped = false + count = px.count() + for i = 0 to count - 1 + k = (i + count - 1) mod count + j = (i + 1) mod count + ax = px[i] - px[k] + ay = py[i] - py[k] + bx = px[j] - px[i] + by = py[j] - py[i] + lengths = Sqr(ax * ax + ay * ay) * Sqr(bx * bx + by * by) + if lengths < 0.000001 or Abs(ax * by - ay * bx) / lengths < 0.0001 + px.Delete(i) + py.Delete(i) + dropped = true + exit for + end if + end for + end while + if px.count() < 3 + return invalid + end if + return {xs: px, ys: py} + end function + +end namespace diff --git a/src/source/engine/renderer/occlusion/ConvexInset.spec.bs b/src/source/engine/renderer/occlusion/ConvexInset.spec.bs new file mode 100644 index 00000000..d04e1195 --- /dev/null +++ b/src/source/engine/renderer/occlusion/ConvexInset.spec.bs @@ -0,0 +1,107 @@ +import "ConvexInset.bs" + +namespace tests + + @suite("BGE.Occlusion.insetConvexPolygon") + class ConvexInsetTests extends rooibos.BaseTestSuite + + ' Whether the polygon has a vertex within 0.001 of (x, y). + private function hasVertex(polygon as BGE.CanvasPolygon, x as float, y as float) as boolean + for i = 0 to polygon.xs.count() - 1 + if Abs(polygon.xs[i] - x) < 0.001 and Abs(polygon.ys[i] - y) < 0.001 + return true + end if + end for + return false + end function + + @describe("insetting") + + @it("insets a square on every side") + function _() + inset = BGE.Occlusion.insetConvexPolygon([0.0, 10.0, 10.0, 0.0], [0.0, 0.0, 10.0, 10.0], 2) + m.assertNotInvalid(inset) + m.assertEqual(4, inset.xs.count()) + m.assertTrue(m.hasVertex(inset, 2, 2)) + m.assertTrue(m.hasVertex(inset, 8, 2)) + m.assertTrue(m.hasVertex(inset, 8, 8)) + m.assertTrue(m.hasVertex(inset, 2, 8)) + end function + + @it("insets a square wound the other way the same") + function _() + inset = BGE.Occlusion.insetConvexPolygon([0.0, 0.0, 10.0, 10.0], [0.0, 10.0, 10.0, 0.0], 2) + m.assertNotInvalid(inset) + m.assertEqual(4, inset.xs.count()) + m.assertTrue(m.hasVertex(inset, 2, 2)) + m.assertTrue(m.hasVertex(inset, 8, 2)) + m.assertTrue(m.hasVertex(inset, 8, 8)) + m.assertTrue(m.hasVertex(inset, 2, 8)) + end function + + @it("insets a triangle's slanted edge by its perpendicular distance") + function _() + corner = 11 - Sqr(2) + for each points in [[[0.0, 12.0, 0.0], [0.0, 0.0, 12.0]], [[0.0, 0.0, 12.0], [0.0, 12.0, 0.0]]] + inset = BGE.Occlusion.insetConvexPolygon(points[0], points[1], 1) + m.assertNotInvalid(inset) + m.assertEqual(3, inset.xs.count()) + m.assertTrue(m.hasVertex(inset, 1, 1)) + m.assertTrue(m.hasVertex(inset, corner, 1)) + m.assertTrue(m.hasVertex(inset, 1, corner)) + end for + end function + + @it("ignores a vertex lying on an edge") + function _() + inset = BGE.Occlusion.insetConvexPolygon([0.0, 5.0, 10.0, 10.0, 0.0], [0.0, 0.0, 0.0, 10.0, 10.0], 2) + m.assertNotInvalid(inset) + m.assertEqual(4, inset.xs.count()) + m.assertTrue(m.hasVertex(inset, 8, 8)) + end function + + @it("returns the polygon unchanged for an inset of 0") + function _() + inset = BGE.Occlusion.insetConvexPolygon([0.0, 10.0, 10.0, 0.0], [0.0, 0.0, 10.0, 10.0], 0) + m.assertNotInvalid(inset) + m.assertTrue(m.hasVertex(inset, 0, 0)) + m.assertTrue(m.hasVertex(inset, 10, 10)) + end function + + @describe("collapsing") + + @it("returns invalid once the inset reaches the middle of a square") + function _() + m.assertInvalid(BGE.Occlusion.insetConvexPolygon([0.0, 10.0, 10.0, 0.0], [0.0, 0.0, 10.0, 10.0], 5)) + m.assertInvalid(BGE.Occlusion.insetConvexPolygon([0.0, 0.0, 10.0, 10.0], [0.0, 10.0, 10.0, 0.0], 6)) + end function + + @it("returns invalid when a long thin quad is narrower than twice the inset") + function _() + m.assertInvalid(BGE.Occlusion.insetConvexPolygon([0.0, 100.0, 100.0, 0.0], [0.0, 0.0, 3.0, 3.0], 2)) + end function + + @it("returns invalid once the inset passes a triangle's inradius") + function _() + ' inradius of this right triangle is 12 - 6 * sqrt(2), about 3.5 + m.assertInvalid(BGE.Occlusion.insetConvexPolygon([0.0, 12.0, 0.0], [0.0, 0.0, 12.0], 4)) + m.assertNotInvalid(BGE.Occlusion.insetConvexPolygon([0.0, 12.0, 0.0], [0.0, 0.0, 12.0], 3)) + end function + + @it("returns invalid for fewer than 3 distinct corners") + function _() + m.assertInvalid(BGE.Occlusion.insetConvexPolygon([0.0, 10.0, 20.0], [0.0, 0.0, 0.0], 1)) + m.assertInvalid(BGE.Occlusion.insetConvexPolygon([0.0, 10.0], [0.0, 0.0], 0)) + end function + + @describe("area") + + @it("measures area in either winding") + function _() + m.assertEqual(100.0, BGE.Occlusion.getPolygonArea([0.0, 10.0, 10.0, 0.0], [0.0, 0.0, 10.0, 10.0])) + m.assertEqual(100.0, BGE.Occlusion.getPolygonArea([0.0, 0.0, 10.0, 10.0], [0.0, 10.0, 10.0, 0.0])) + end function + + end class + +end namespace diff --git a/src/source/engine/renderer/occlusion/OccluderPanels.bs b/src/source/engine/renderer/occlusion/OccluderPanels.bs new file mode 100644 index 00000000..8e51f851 --- /dev/null +++ b/src/source/engine/renderer/occlusion/OccluderPanels.bs @@ -0,0 +1,217 @@ +import "OcclusionTypes.bs" +import "../../drawables/Model3d.bs" +import "../../../math/vector.bs" +import "../../../math/math.bs" + +namespace BGE.Occlusion + + ' Merges a static model's coplanar, edge-sharing triangles into convex panels that can + ' act as occluders. Panels keep the source triangles' winding. + ' + ' @param {BGE.Model3dFace[]} faces triangles in model-local space + ' @return {BGE.OccluderPanel[]} one panel per merged region; degenerate triangles are dropped + function buildModelPanels(faces as BGE.Model3dFace[]) as BGE.OccluderPanel[] + panels = [] + size = getFacesSize(faces) + if size <= 0 + return panels + end if + posEpsilon = size * 0.001 + planeEpsilon = size * 0.01 + + ' Each group is one plane: its unit normal, offset and the polygons found on it. + groups = [] + for each face in faces + if face.vertices.Count() <> 3 + continue for + end if + v0 = face.vertices[0] + cross = BGE.Math.VectorOps.crossProduct(BGE.Math.VectorOps.subtract(face.vertices[1], v0), BGE.Math.VectorOps.subtract(face.vertices[2], v0)) + if BGE.Math.VectorOps.length(cross) < size * size * 0.000001 + continue for + end if + normal = BGE.Math.VectorOps.getNormalizedCopy(cross) + offset = BGE.Math.VectorOps.dotProduct(normal, v0) + + target = invalid + for each group in groups + if BGE.Math.VectorOps.dotProduct(normal, group.normal) > 0.999 and Abs(BGE.Math.VectorOps.dotProduct(group.normal, v0) - group.offset) < planeEpsilon + target = group + exit for + end if + end for + if target = invalid + target = {normal: normal, offset: offset, polygons: []} + groups.push(target) + end if + target.polygons.push([face.vertices[0], face.vertices[1], face.vertices[2]]) + end for + + for each group in groups + mergePolygons(group.polygons, group.normal, posEpsilon) + for each polygon in group.polygons + cleaned = dropCollinear(polygon, group.normal) + if cleaned.Count() >= 3 + ' merging keeps every source corner, so any not on the cleaned edge is inside it + hasInnerVertices = false + for each vertex in polygon + if not isOnPolygonEdge(vertex, cleaned, posEpsilon) + hasInnerVertices = true + exit for + end if + end for + panels.push({vertices: cleaned, normal: group.normal, hasInnerVertices: hasInnerVertices}) + end if + end for + end for + return panels + end function + + ' Largest extent of the model along any axis. + function getFacesSize(faces as BGE.Model3dFace[]) as float + points = [] + for each face in faces + for each v in face.vertices + points.push(v) + end for + end for + if points.Count() = 0 + return 0.0 + end if + bounds = BGE.Math.getBounds(points) + extent = BGE.Math.VectorOps.subtract(bounds[1], bounds[0]) + largest = extent.x + if extent.y > largest + largest = extent.y + end if + if extent.z > largest + largest = extent.z + end if + return largest + end function + + ' Greedily merges polygons that share an edge while the result stays convex. + sub mergePolygons(polygons as BGE.Math.Vector[][], normal as BGE.Math.Vector, epsilon as float) + merged = true + while merged + merged = false + for i = 0 to polygons.Count() - 2 + for j = i + 1 to polygons.Count() - 1 + candidate = tryMerge(polygons[i], polygons[j], normal, epsilon) + if candidate <> invalid + polygons[i] = candidate + polygons.Delete(j) + merged = true + exit for + end if + end for + if merged + exit for + end if + end for + end while + end sub + + ' Joins two polygons along a shared edge (walked in opposite directions, as same-winding + ' neighbours do), or returns invalid when they share none or the union isn't convex. + function tryMerge(p as BGE.Math.Vector[], q as BGE.Math.Vector[], normal as BGE.Math.Vector, epsilon as float) as BGE.Math.Vector[] + pCount = p.Count() + qCount = q.Count() + for i = 0 to pCount - 1 + a = p[i] + b = p[(i + 1) mod pCount] + for j = 0 to qCount - 1 + if not isSamePoint(q[j], b, epsilon) or not isSamePoint(q[(j + 1) mod qCount], a, epsilon) + continue for + end if + result = [] + for k = 0 to i + result.push(p[k]) + end for + for k = 2 to qCount - 1 + result.push(q[(j + k) mod qCount]) + end for + for k = i + 1 to pCount - 1 + result.push(p[k]) + end for + cleaned = dropCollinear(result, normal) + if cleaned.Count() >= 3 and isConvex(cleaned, normal) + return result + end if + return invalid + end for + end for + return invalid + end function + + ' Whether `point` lies within `epsilon` of one of the polygon's edges. + function isOnPolygonEdge(point as BGE.Math.Vector, polygon as BGE.Math.Vector[], epsilon as float) as boolean + count = polygon.Count() + for i = 0 to count - 1 + a = polygon[i] + edge = BGE.Math.VectorOps.subtract(polygon[(i + 1) mod count], a) + lengthSquared = BGE.Math.VectorOps.dotProduct(edge, edge) + t = 0.0 + if lengthSquared > 0 + t = BGE.Math.VectorOps.dotProduct(BGE.Math.VectorOps.subtract(point, a), edge) / lengthSquared + t = BGE.Math.Max(0.0, BGE.Math.Min(1.0, t)) + end if + closest = BGE.Math.VectorOps.add(a, BGE.Math.VectorOps.scale(edge, t)) + if isSamePoint(point, closest, epsilon) + return true + end if + end for + return false + end function + + function isSamePoint(a as BGE.Math.Vector, b as BGE.Math.Vector, epsilon as float) as boolean + return BGE.Math.VectorOps.length(BGE.Math.VectorOps.subtract(a, b)) <= epsilon + end function + + ' The turn at b (coming from a, going to c), signed against the plane normal and + ' scaled by the edge lengths so it is the sine of the angle. + function getTurnSine(a as BGE.Math.Vector, b as BGE.Math.Vector, c as BGE.Math.Vector, normal as BGE.Math.Vector) as float + e1 = BGE.Math.VectorOps.subtract(b, a) + e2 = BGE.Math.VectorOps.subtract(c, b) + lengths = BGE.Math.VectorOps.length(e1) * BGE.Math.VectorOps.length(e2) + if lengths = 0 + return 0.0 + end if + return BGE.Math.VectorOps.dotProduct(BGE.Math.VectorOps.crossProduct(e1, e2), normal) / lengths + end function + + function dropCollinear(polygon as BGE.Math.Vector[], normal as BGE.Math.Vector) as BGE.Math.Vector[] + current = polygon + changed = true + while changed and current.Count() >= 3 + changed = false + count = current.Count() + for i = 0 to count - 1 + sine = getTurnSine(current[(i + count - 1) mod count], current[i], current[(i + 1) mod count], normal) + if Abs(sine) < 0.0001 + kept = [] + for k = 0 to count - 1 + if k <> i + kept.push(current[k]) + end if + end for + current = kept + changed = true + exit for + end if + end for + end while + return current + end function + + function isConvex(polygon as BGE.Math.Vector[], normal as BGE.Math.Vector) as boolean + count = polygon.Count() + for i = 0 to count - 1 + if getTurnSine(polygon[i], polygon[(i + 1) mod count], polygon[(i + 2) mod count], normal) < 0 + return false + end if + end for + return true + end function + +end namespace diff --git a/src/source/engine/renderer/occlusion/OccluderPanels.spec.bs b/src/source/engine/renderer/occlusion/OccluderPanels.spec.bs new file mode 100644 index 00000000..a79078dd --- /dev/null +++ b/src/source/engine/renderer/occlusion/OccluderPanels.spec.bs @@ -0,0 +1,145 @@ +import "OccluderPanels.bs" +import "../../drawables/Model3d.bs" + +namespace tests + + @suite("BGE.Occlusion.buildModelPanels") + class OccluderPanelsTests extends rooibos.BaseTestSuite + + private function tri(a as float[], b as float[], c as float[]) as BGE.Model3dFace + return BGE.Model3dFaceOps.create([ + BGE.Math.VectorOps.create(a[0], a[1], a[2]), + BGE.Math.VectorOps.create(b[0], b[1], b[2]), + BGE.Math.VectorOps.create(c[0], c[1], c[2]) + ]) + end function + + ' True when every turn around the panel bends the same way as its normal. + private function isConvex(panel as BGE.OccluderPanel) as boolean + count = panel.vertices.Count() + for i = 0 to count - 1 + a = panel.vertices[i] + b = panel.vertices[(i + 1) mod count] + c = panel.vertices[(i + 2) mod count] + turn = BGE.Math.VectorOps.crossProduct(BGE.Math.VectorOps.subtract(b, a), BGE.Math.VectorOps.subtract(c, b)) + if BGE.Math.VectorOps.dotProduct(turn, panel.normal) < -0.000001 + return false + end if + end for + return true + end function + + ' Two triangles per face of the unit cube, each face wound counter-clockwise from outside. + private function cubeFaces() as BGE.Model3dFace[] + faces = [] + quads = [ + [[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]], + [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], + [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]], + [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]], + [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]], + [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] + ] + for each q in quads + faces.push(m.tri(q[0], q[1], q[2])) + faces.push(m.tri(q[0], q[2], q[3])) + end for + return faces + end function + + @describe("buildModelPanels") + + @it("merges two coplanar triangles sharing an edge into one 4-vertex panel") + function _() + faces = [ + m.tri([0, 0, 0], [1, 0, 0], [1, 1, 0]), + m.tri([0, 0, 0], [1, 1, 0], [0, 1, 0]) + ] + panels = BGE.Occlusion.buildModelPanels(faces) + m.assertEqual(panels.Count(), 1) + m.assertEqual(panels[0].vertices.Count(), 4) + ' Winding is kept: the panel normal matches the source triangles'. + m.assertTrue(BGE.Math.VectorOps.dotProduct(panels[0].normal, faces[0].normal) > 0.99) + m.assertTrue(m.isConvex(panels[0])) + end function + + @it("keeps triangles on different planes separate") + function _() + faces = [ + m.tri([0, 0, 0], [1, 0, 0], [1, 1, 0]), + m.tri([0, 0, 0], [0, 1, 0], [0, 1, 1]) + ] + m.assertEqual(BGE.Occlusion.buildModelPanels(faces).Count(), 2) + end function + + @it("merges a cube's 12 triangles into 6 panels") + function _() + panels = BGE.Occlusion.buildModelPanels(m.cubeFaces()) + m.assertEqual(panels.Count(), 6) + for each panel in panels + m.assertEqual(panel.vertices.Count(), 4) + m.assertTrue(m.isConvex(panel)) + end for + end function + + @it("does not merge when the result would be non-convex") + function _() + ' An L shape split into four triangles. + faces = [ + m.tri([0, 0, 0], [2, 0, 0], [1, 1, 0]), + m.tri([2, 0, 0], [2, 1, 0], [1, 1, 0]), + m.tri([0, 0, 0], [1, 1, 0], [0, 2, 0]), + m.tri([1, 1, 0], [1, 2, 0], [0, 2, 0]) + ] + panels = BGE.Occlusion.buildModelPanels(faces) + m.assertTrue(panels.Count() > 1) + for each panel in panels + m.assertTrue(m.isConvex(panel)) + end for + end function + + @it("flags no inner corners for a cube's panels") + function _() + for each panel in BGE.Occlusion.buildModelPanels(m.cubeFaces()) + m.assertFalse(panel.hasInnerVertices) + end for + end function + + @it("flags a panel merged from a fan around a corner inside it") + function _() + ' four triangles around the square's centre + faces = [ + m.tri([0, 0, 0], [2, 0, 0], [1, 1, 0]), + m.tri([2, 0, 0], [2, 2, 0], [1, 1, 0]), + m.tri([2, 2, 0], [0, 2, 0], [1, 1, 0]), + m.tri([0, 2, 0], [0, 0, 0], [1, 1, 0]) + ] + panels = BGE.Occlusion.buildModelPanels(faces) + m.assertEqual(panels.Count(), 1) + m.assertEqual(panels[0].vertices.Count(), 4) + m.assertTrue(panels[0].hasInnerVertices) + end function + + @it("does not flag a corner lying on a panel's edge") + function _() + ' a square split into three triangles, one corner partway along its bottom edge + faces = [ + m.tri([0, 0, 0], [1, 0, 0], [0, 2, 0]), + m.tri([1, 0, 0], [2, 0, 0], [2, 2, 0]), + m.tri([1, 0, 0], [2, 2, 0], [0, 2, 0]) + ] + panels = BGE.Occlusion.buildModelPanels(faces) + m.assertEqual(panels.Count(), 1) + m.assertEqual(panels[0].vertices.Count(), 4) + m.assertFalse(panels[0].hasInnerVertices) + end function + + @it("ignores degenerate faces") + function _() + faces = [m.tri([0, 0, 0], [1, 0, 0], [2, 0, 0])] + m.assertEqual(BGE.Occlusion.buildModelPanels(faces).Count(), 0) + end function + + end class + +end namespace diff --git a/src/source/engine/renderer/occlusion/OcclusionSpanMap.bs b/src/source/engine/renderer/occlusion/OcclusionSpanMap.bs new file mode 100644 index 00000000..c84e4e6b --- /dev/null +++ b/src/source/engine/renderer/occlusion/OcclusionSpanMap.bs @@ -0,0 +1,272 @@ +import "OcclusionTypes.bs" + +namespace BGE + + ' Per horizontal band of the canvas, the merged x ranges that occluders cover across the + ' whole band, each with the farthest depth of the occluders merged into it (conservative). + ' An object is hidden only if one merged range covers it in every band it touches. + class OcclusionSpanMap + + private width as float + private height as float + private bandHeight as float + private bandCount as integer + ' Per band, a flat [left, right, depth, left, right, depth, ...] list. + private bandSpans as float[][] = [] + ' The nearest depth of any span, 1e30 with no spans. + private nearestDepth as float = 1e30 + + ' @param width - canvas width in pixels + ' @param height - canvas height in pixels + ' @param bandCount - number of horizontal bands the canvas is divided into + sub new(width as float, height as float, bandCount as integer) + m.width = width + m.height = height + m.bandCount = bandCount + m.bandHeight = height / bandCount + end sub + + ' Rebuilds the map from this frame's occluders, replacing the previous frame's spans. + ' @param polygons - convex canvas-space occluders + sub build(polygons as BGE.OccluderPolygon[]) + if m.bandSpans.count() <> m.bandCount + m.bandSpans = [] + for r = 0 to m.bandCount - 1 + m.bandSpans.push([]) + end for + else + for each row in m.bandSpans + row.Clear() + end for + end if + bandHeight = m.bandHeight + lastBand = m.bandCount - 1 + for each poly in polygons + xs = poly.xs + ys = poly.ys + n = xs.count() + top = 0 + bottom = 0 + for i = 1 to n - 1 + if ys[i] < ys[top] + top = i + end if + if ys[i] > ys[bottom] + bottom = i + end if + end for + ' Only bands lying wholly inside the polygon's y range can be fully covered. + r0 = Int(ys[top] / bandHeight) + if r0 * bandHeight < ys[top] + r0++ + end if + r1 = Int(ys[bottom] / bandHeight) - 1 + if r0 < 0 + r0 = 0 + end if + if r1 > lastBand + r1 = lastBand + end if + if r1 >= r0 + boundaries = r1 - r0 + 2 + y0 = r0 * bandHeight + ' A convex polygon splits into two y-monotonic chains from top to bottom vertex. + a = m.chainXs(xs, ys, top, bottom, 1, y0, bandHeight, boundaries) + b = m.chainXs(xs, ys, top, bottom, -1, y0, bandHeight, boundaries) + for k = 0 to boundaries - 2 + ' Each chain is linear between vertices, so a band's inner bound is at one of its ends. + a0 = a[k] + a1 = a[k + 1] + b0 = b[k] + b1 = b[k + 1] + if a0 < b0 + l = a0 + if a1 > l + l = a1 + end if + rr = b0 + if b1 < rr + rr = b1 + end if + else + l = b0 + if b1 > l + l = b1 + end if + rr = a0 + if a1 < rr + rr = a1 + end if + end if + if rr > l + m.insertSpan(m.bandSpans[r0 + k], l, rr, poly.depth) + end if + end for + end if + end for + ' merging keeps the farther depth, so read it back from the spans + m.nearestDepth = 1e30 + for each row in m.bandSpans + for i = 2 to row.count() - 1 step 3 + if row[i] < m.nearestDepth + m.nearestDepth = row[i] + end if + end for + end for + end sub + + ' The nearest depth any span claims. An object reaching this near can't be hidden. + ' @return a very large number when the map is empty + function getNearestDepth() as float + return m.nearestDepth + end function + + ' Whether a span strictly nearer than `depth` covers the canvas point. An object that + ' draws over this point no nearer than `depth` can only be hidden if it is. + ' @param x - canvas x + ' @param y - canvas y + ' @param depth - the depth to compare spans against + ' @return {boolean} + function isPointCovered(x as float, y as float, depth as float) as boolean + if x < 0 or y < 0 or x > m.width or y > m.height + return false + end if + r = Int(y / m.bandHeight) + if r > m.bandCount - 1 + r = m.bandCount - 1 + end if + spans = m.bandSpans[r] + for i = 0 to spans.count() - 1 step 3 + if spans[i] <= x and spans[i + 1] >= x and spans[i + 2] < depth + return true + end if + end for + return false + end function + + ' True only if the box is covered in every band it touches by one merged span whose depth + ' is strictly nearer than nearestDepth. A box extending off the canvas is never hidden. + ' The caller inflates the box by any safety margin first. + ' @param minX - left edge of the box + ' @param minY - top edge of the box + ' @param maxX - right edge of the box + ' @param maxY - bottom edge of the box + ' @param nearestDepth - depth of the nearest point of the object + ' @return true if the object is certainly hidden + function isHidden(minX as float, minY as float, maxX as float, maxY as float, nearestDepth as float) as boolean + if minX < 0 or minY < 0 or maxX > m.width or maxY > m.height + return false + end if + ' An empty or inverted box would pass the band loop vacuously. + if maxX <= minX or maxY <= minY + return false + end if + r0 = Int(minY / m.bandHeight) + r1 = Int(maxY / m.bandHeight) + if r1 * m.bandHeight = maxY + r1-- + end if + if r1 > m.bandCount - 1 + r1 = m.bandCount - 1 + end if + if r1 < r0 + return false + end if + for r = r0 to r1 + spans = m.bandSpans[r] + found = false + for i = 0 to spans.count() - 1 step 3 + if spans[i] <= minX and spans[i + 1] >= maxX and spans[i + 2] < nearestDepth + found = true + exit for + end if + end for + if not found + return false + end if + end for + return true + end function + + ' @return the height of each band in pixels + function getBandHeight() as float + return m.bandHeight + end function + + ' @return the number of bands + function getBandCount() as integer + return m.bandCount + end function + + ' @param band - band index, 0 at the top + ' @return the band's merged spans as a flat [left, right, depth, ...] list, for debug drawing + function getBandSpans(band as integer) as float[] + return m.bandSpans[band] + end function + + ' x of the chain walked from vertex `top` in direction `dir` (+1/-1) at y = y0 + k*stepY. + private function chainXs(xs as float[], ys as float[], top as integer, bottom as integer, dir as integer, y0 as float, stepY as float, count as integer) as float[] + n = xs.count() + result = CreateObject("roArray", count, false) + i = top + j = (i + dir + n) mod n + y = y0 + for k = 0 to count - 1 + while ys[j] < y and j <> bottom + i = j + j = (i + dir + n) mod n + end while + dy = ys[j] - ys[i] + if dy > 0 + result.push(xs[i] + (y - ys[i]) * (xs[j] - xs[i]) / dy) + else + result.push(xs[j]) + end if + y += stepY + end for + return result + end function + + ' Inserts [l, r] into a band's sorted, merged flat span list, merging any it touches. + private sub insertSpan(row as float[], l as float, r as float, d as float) + n = row.count() + i = 0 + while i < n and row[i + 1] < l + i += 3 + end while + if i = n + row.push(l) + row.push(r) + row.push(d) + return + end if + j = i + while j < n and row[j] <= r + if row[j] < l + l = row[j] + end if + if row[j + 1] > r + r = row[j + 1] + end if + if row[j + 2] > d + d = row[j + 2] + end if + j += 3 + end while + rebuilt = [] + for k = 0 to i - 1 + rebuilt.push(row[k]) + end for + rebuilt.push(l) + rebuilt.push(r) + rebuilt.push(d) + for k = j to n - 1 + rebuilt.push(row[k]) + end for + row.Clear() + row.append(rebuilt) + end sub + + end class + +end namespace diff --git a/src/source/engine/renderer/occlusion/OcclusionSpanMap.spec.bs b/src/source/engine/renderer/occlusion/OcclusionSpanMap.spec.bs new file mode 100644 index 00000000..d3714eae --- /dev/null +++ b/src/source/engine/renderer/occlusion/OcclusionSpanMap.spec.bs @@ -0,0 +1,129 @@ +import "OcclusionSpanMap.bs" + +namespace tests + + @suite("BGE.OcclusionSpanMap") + class OcclusionSpanMapTests extends rooibos.BaseTestSuite + + map as BGE.OcclusionSpanMap + + protected override function beforeEach() + m.map = new BGE.OcclusionSpanMap(1280, 720, 45) + end function + + private function quad(x0 as float, y0 as float, x1 as float, y1 as float, depth as float) as BGE.OccluderPolygon + return {xs: [x0, x1, x1, x0], ys: [y0, y0, y1, y1], depth: depth, area: (x1 - x0) * (y1 - y0)} + end function + + @describe("isHidden") + + @it("hides a box fully inside one nearer quad") + function _() + m.map.build([m.quad(100, 100, 600, 500, 10)]) + m.assertTrue(m.map.isHidden(200, 200, 300, 300, 20)) + end function + + @it("does not hide an object nearer than the occluder") + function _() + m.map.build([m.quad(100, 100, 600, 500, 10)]) + m.assertFalse(m.map.isHidden(200, 200, 300, 300, 5)) + end function + + @it("hides a box covered only by the union of two touching quads") + function _() + m.map.build([m.quad(100, 100, 400, 500, 10), m.quad(400, 100, 700, 500, 12)]) + m.assertTrue(m.map.isHidden(350, 200, 450, 300, 20)) + ' A merged range keeps the farthest depth. + m.assertFalse(m.map.isHidden(350, 200, 450, 300, 11)) + end function + + @it("does not hide across a gap between two occluders") + function _() + m.map.build([m.quad(100, 100, 399, 500, 10), m.quad(401, 100, 700, 500, 10)]) + m.assertFalse(m.map.isHidden(350, 200, 450, 300, 20)) + end function + + @it("does not hide a box poking out of the occluder") + function _() + m.map.build([m.quad(100, 100, 600, 500, 10)]) + m.assertFalse(m.map.isHidden(550, 200, 650, 300, 20)) + end function + + @it("only counts bands lying wholly inside a triangle") + function _() + m.map.build([{xs: [100.0, 700.0, 400.0], ys: [100.0, 100.0, 600.0], depth: 10.0, area: 150000.0}]) + m.assertFalse(m.map.isHidden(390, 560, 410, 590, 20)) + m.assertTrue(m.map.isHidden(380, 150, 420, 200, 20)) + end function + + @it("treats a box spanning several bands as hidden only if every band covers it") + function _() + m.map.build([m.quad(100, 100, 600, 500, 10)]) + m.assertFalse(m.map.isHidden(200, 90, 300, 300, 20)) + end function + + @it("never hides a box that extends off the canvas") + function _() + m.map.build([m.quad(0, 0, 1280, 720, 10)]) + m.assertFalse(m.map.isHidden(-10, 200, 50, 300, 20)) + end function + + @it("never hides a zero-height or inverted box") + function _() + m.map.build([m.quad(0, 0, 1280, 720, 10)]) + m.assertFalse(m.map.isHidden(200, 160, 300, 160, 20)) + m.assertFalse(m.map.isHidden(200, 0, 300, 0, 20)) + m.assertFalse(m.map.isHidden(200, 300, 300, 200, 20)) + m.assertFalse(m.map.isHidden(300, 200, 200, 300, 20)) + end function + + @it("hides nothing when built with no polygons") + function _() + m.map.build([]) + m.assertFalse(m.map.isHidden(200, 200, 300, 300, 20)) + end function + + @it("rebuilding replaces the previous frame's spans") + function _() + m.map.build([m.quad(100, 100, 600, 500, 10)]) + m.map.build([]) + m.assertFalse(m.map.isHidden(200, 200, 300, 300, 20)) + end function + + @describe("quick checks") + + @it("reports the nearest span depth, which a merge raises to the farther one") + function _() + m.map.build([m.quad(100, 100, 400, 500, 10), m.quad(600, 100, 700, 500, 30)]) + m.assertEqual(m.map.getNearestDepth(), 10.0) + m.map.build([m.quad(100, 100, 400, 500, 10), m.quad(400, 100, 700, 500, 30)]) + m.assertEqual(m.map.getNearestDepth(), 30.0) + end function + + @it("reports no nearest depth for an empty map") + function _() + m.map.build([]) + m.assertTrue(m.map.getNearestDepth() > 1e20) + end function + + @it("covers a point only inside a nearer span") + function _() + m.map.build([m.quad(100, 100, 400, 500, 10)]) + m.assertTrue(m.map.isPointCovered(250, 300, 20)) + m.assertFalse(m.map.isPointCovered(250, 300, 10)) + m.assertFalse(m.map.isPointCovered(450, 300, 20)) + m.assertFalse(m.map.isPointCovered(250, 50, 20)) + m.assertFalse(m.map.isPointCovered(-1, 300, 20)) + end function + + @describe("band accessors") + + @it("reports band height and count") + function _() + m.assertEqual(m.map.getBandCount(), 45) + m.assertEqual(m.map.getBandHeight(), 16.0) + end function + + end class + +end namespace diff --git a/src/source/engine/renderer/occlusion/OcclusionTypes.bs b/src/source/engine/renderer/occlusion/OcclusionTypes.bs new file mode 100644 index 00000000..9c4cc825 --- /dev/null +++ b/src/source/engine/renderer/occlusion/OcclusionTypes.bs @@ -0,0 +1,60 @@ +import "../../../math/vector.bs" + +namespace BGE + + ' Whether a SceneObject was judged hidden by occlusion culling this frame. + enum VisibilityState + unknown = "unknown" + visible = "visible" + occluded = "occluded" + offCamera = "offCamera" + end enum + + ' How occluders draw while occlusion culling relies on them (see Renderer.occluderDrawPolicy). + enum OccluderDrawPolicy + ' Occluders always draw exactly - no reused bitmaps, image fast path or coarse fills - + ' and claim their exact outline. Slowest, never glitches. + exact = "exact" + ' Occluders take the fast draw paths, and while one may run each occluder's claim is + ' shrunk by that path's worst error, so nothing it doesn't paint is hidden. + fastShrink = "fastShrink" + ' Occluders take the fast draw paths but still claim their exact outline, so an object + ' just behind an occluder's edge can be hidden for a frame while the camera moves. + fastExactClaim = "fastExactClaim" + end enum + + ' A convex canvas-space polygon that is guaranteed to hide whatever lies behind it. + interface OccluderPolygon extends roAssociativeArray + ' Canvas-space vertices, either winding, 3 or more points. + xs as float[] + ys as float[] + ' The farthest vertex's distanceFromCameraFront. + depth as float + ' Projected area, used to keep the largest occluders when capping their number. + area as float + end interface + + ' A canvas-space polygon, its vertices in order around it. + interface CanvasPolygon extends roAssociativeArray + xs as float[] + ys as float[] + end interface + + ' A canvas-space box an object was tested against the occlusion map with. + interface OcclusionTestBounds extends roAssociativeArray + minX as float + minY as float + maxX as float + maxY as float + end interface + + ' A convex, opaque face of a model, in model-local space. + interface OccluderPanel extends roAssociativeArray + ' Ordered around the perimeter. + vertices as BGE.Math.Vector[] + normal as BGE.Math.Vector + ' Whether a source triangle has a corner inside the panel rather than on its edge. + hasInnerVertices as boolean + end interface + +end namespace diff --git a/src/source/engine/renderer/occlusion/VisibilityRank.bs b/src/source/engine/renderer/occlusion/VisibilityRank.bs new file mode 100644 index 00000000..7b6028bc --- /dev/null +++ b/src/source/engine/renderer/occlusion/VisibilityRank.bs @@ -0,0 +1,29 @@ +import "OcclusionTypes.bs" + +namespace BGE + + ' Returns whichever state is more visible, in the order visible > occluded > offCamera > + ' unknown. Used to roll a SceneObject's state up into its Drawable and GameEntity. + ' + ' @param {BGE.VisibilityState} a + ' @param {BGE.VisibilityState} b + ' @return {BGE.VisibilityState} + function mostVisibleState(a as BGE.VisibilityState, b as BGE.VisibilityState) as BGE.VisibilityState + if BGE.visibilityRank(b) > BGE.visibilityRank(a) + return b + end if + return a + end function + + function visibilityRank(state as BGE.VisibilityState) as integer + if state = BGE.VisibilityState.visible + return 3 + else if state = BGE.VisibilityState.occluded + return 2 + else if state = BGE.VisibilityState.offCamera + return 1 + end if + return 0 + end function + +end namespace diff --git a/src/source/engine/renderer/sceneObjects/SceneObject.bs b/src/source/engine/renderer/sceneObjects/SceneObject.bs index cf28b089..7943131f 100644 --- a/src/source/engine/renderer/sceneObjects/SceneObject.bs +++ b/src/source/engine/renderer/sceneObjects/SceneObject.bs @@ -5,6 +5,7 @@ import "../../../utils/deviceRenderTier.bs" import "../../drawables/Drawable.bs" import "../cameras/Camera.bs" import "../Renderer.bs" +import "../occlusion/OcclusionTypes.bs" namespace BGE @@ -128,6 +129,18 @@ namespace BGE return dotProductResult > 0.0001 end function + ' Model faces carry outward normals (counter-clockwise winding seen from outside, the + ' .obj/.stl convention), so a face is front-facing when its normal points back toward + ' the camera. + function isOutwardNormalFacingCamera(rendererObj as BGE.Renderer, facePoint as BGE.Math.Vector, faceNormal as BGE.Math.Vector) as boolean + return getDotProductFromSurfaceToCamera(rendererObj, facePoint, faceNormal) < -0.0001 + end function + + ' How directly an outward normal faces the camera: 1 head on, 0 or less edge-on or away. + function getOutwardNormalFacingAmount(rendererObj as BGE.Renderer, facePoint as BGE.Math.Vector, faceNormal as BGE.Math.Vector) as float + return -getDotProductFromSurfaceToCamera(rendererObj, facePoint, faceNormal) + end function + class SceneObject name as string @@ -188,6 +201,8 @@ namespace BGE ' deterministic draw failures, like a backface or an off-canvas rejection, which used ' to re-run the full frustum check every single frame with no latch at all). protected lastFrameWasCulled = false + ' What the last draw (or skip) reported about this object. See getVisibility(). + protected visibility as BGE.VisibilityState = BGE.VisibilityState.unknown ' Whether the last frame's draw actually happened. Distinct from lastFrameWasCulled ' because a frame has four possible outcomes, not two: drew, was culled by the @@ -242,6 +257,29 @@ namespace BGE ' meant to be set directly by game code - see Renderer.markStatic(). isStatic as boolean = false + ' Set by Renderer.markOccluder() when this object hides what's behind it. Not meant + ' to be set directly - flag the Drawable's isOccluder instead. + isOccluder as boolean = false + ' The Drawable's isOccluder the Renderer last acted on. Internal. + occluderFlagSeen as boolean = false + ' The renderer's occluderDrawPolicy, set by the Renderer for an occluder. Read-only. + occluderDrawPolicy as BGE.OccluderDrawPolicy = BGE.OccluderDrawPolicy.fastShrink + + ' Whether occlusion culling hides this object this frame. Set by the Renderer; read-only. + occludedThisFrame as boolean = false + ' The Renderer's occlusion map version occludedThisFrame was judged against, -1 if none. + occlusionMapVersion as integer = -1 + ' The inflated canvas box occludedThisFrame was judged on, or invalid when this object + ' isn't being tested for occlusion. Read-only. + occlusionTestBounds as BGE.OcclusionTestBounds = invalid + ' Whether this occluder could contribute to the occlusion map as of the last + ' occluderChangedThisFrame() call. + private occluderWasContributing as boolean = false + + ' The inset and quality level this occluder's claim was last judged with. + private occluderLastInset as float = 0.0 + private occluderLastQualityVersion as integer = -1 + ' True for exactly the frame Renderer.updateSceneObjects() should mark the static ' geometry tree dirty because a static-flagged object's transform changed after ' registration - see update()'s misuse-detection block. @@ -393,10 +431,82 @@ namespace BGE m.lastDrawMode = m.getActualDrawMode(cameraObj) m.lastGeometryVersion = m.drawable.geometryVersion m.lastFrameWasCulled = true + m.visibility = BGE.VisibilityState.offCamera m.lastFrameDidDraw = false m.isFirstFrameSinceEnabled = false end sub + ' Called by Renderer instead of draw() for an object it skips because something in front + ' hides it. Unlike a cull this doesn't latch, and the canvas geometry is dropped so the + ' next draw() recomputes it. Projection and quality versions are deliberately not + ' recorded, so a change that happens while hidden is applied when it returns. + ' + ' @param {Camera} cameraObj the renderer's camera + sub markSkippedOccluded(cameraObj as Camera) + m.lastDrawMode = m.getActualDrawMode(cameraObj) + m.lastGeometryVersion = m.drawable.geometryVersion + m.isFirstFrameSinceEnabled = false + m.lastFrameDidDraw = false + m.hasValidCanvasPosition = false + m.lastFrameWasCulled = false + m.visibility = BGE.VisibilityState.occluded + end sub + + ' Whether this object's shape on the canvas may have changed this frame: it or the + ' camera moved, its geometry or draw mode changed, or it was just enabled. Call after + ' update() and before draw() for the frame. + ' + ' @return {boolean} + function canvasShapeMayHaveChanged() as boolean + return m.depthChangedThisFrame or m.isFirstFrameSinceEnabled or m.geometryChanged() + end function + + ' Whether this occluder's contribution to the occlusion map may differ from last time + ' this was called. Renderer calls it once per frame for every occluder, after update(). + ' + ' @param {Renderer} rendererObj + ' @return {boolean} + function occluderChangedThisFrame(rendererObj as Renderer) as boolean + drawMode = m.getOccluderDrawMode(rendererObj) + contributing = drawMode >= 0 + inset = 0.0 + if contributing + inset = m.getOccluderInset(rendererObj, drawMode) + end if + ' the claim grows back once it stops moving, and quality sets the approximations + changed = contributing <> m.occluderWasContributing or inset <> m.occluderLastInset or rendererObj.qualityVersion <> m.occluderLastQualityVersion + m.occluderWasContributing = contributing + m.occluderLastInset = inset + m.occluderLastQualityVersion = rendererObj.qualityVersion + return changed or (contributing and m.canvasShapeMayHaveChanged()) + end function + + ' The world points occlusion culling tests this object by - its canvas extent must lie + ' within their projection. invalid when this object must never be judged hidden. + ' + ' @param {Camera} cameraObj + ' @return {BGE.Math.Vector[]} + function getOcclusionTestPoints(cameraObj as Camera) as BGE.Math.Vector[] + return m.getPositionsForOcclusionTest(cameraObj, m.getActualDrawMode(cameraObj)) + end function + + ' Whether this type can ever be hidden by occluders. Override to return false for a + ' type whose drawn extent isn't known before it draws. + ' + ' @return {boolean} + function canBeOccluded() as boolean + return true + end function + + ' Override for a type whose bounding points don't bound what it draws in some mode. + ' + ' @param {Camera} cameraObj + ' @param {SceneObjectDrawMode} drawMode + ' @return {BGE.Math.Vector[]} + protected function getPositionsForOcclusionTest(cameraObj as Camera, drawMode as SceneObjectDrawMode) as BGE.Math.Vector[] + return m.getBoundingPoints(cameraObj) + end function + ' Check if the object is enabled ' ' @return {boolean} true if this sceneObject should be drawn or updated @@ -456,6 +566,12 @@ namespace BGE end if end if m.lastFrameWasCulled = not enteredDrawPath or deterministicFailure + ' A transient draw failure still reports visible, as isOnScreen() always has. + if m.lastFrameWasCulled + m.visibility = BGE.VisibilityState.offCamera + else + m.visibility = BGE.VisibilityState.visible + end if m.lastFrameDidDraw = didDraw m.isFirstFrameSinceEnabled = false m.lastGeometryVersion = m.drawable.geometryVersion @@ -715,6 +831,81 @@ namespace BGE return false end function + ' Whether this type can act as an occluder (see Drawable.isOccluder): opaque, with a + ' known flat-panel silhouette. False by default; Image, Rectangle and Model opt in. + ' + ' @return {boolean} + function isEligibleOccluderType() as boolean + return false + end function + + ' Hook called by Renderer.markOccluder() once this object is accepted as an occluder, + ' for a type that needs to precompute something. Does nothing by default. + sub prepareOccluder() + end sub + + ' Adds the canvas-space polygons this occluder is guaranteed to cover this frame to + ' `out` - none when it doesn't qualify (see Drawable.isOccluder). When an approximate + ' draw path (a reused bitmap, a coarse fill, the image fast path) may run this frame, + ' each polygon is shrunk to what that path still paints. Call after update() for the + ' frame. Adds nothing by default. + ' + ' @param {Renderer} rendererObj + ' @param {BGE.OccluderPolygon[]} out + function appendOccluderPolygons(rendererObj as Renderer, out as BGE.OccluderPolygon[]) as void + end function + + ' Whether this is an occluder that must draw exactly (OccluderDrawPolicy.exact). + ' + ' @return {boolean} + protected function drawsOccluderExactly() as boolean + return m.isOccluder and m.occluderDrawPolicy = BGE.OccluderDrawPolicy.exact + end function + + ' Whether this occluder's claim is shrunk while an approximate draw path may run + ' (OccluderDrawPolicy.fastShrink) - otherwise it claims its exact outline. + ' + ' @return {boolean} + protected function shrinksOccluderClaim() as boolean + return m.occluderDrawPolicy = BGE.OccluderDrawPolicy.fastShrink + end function + + ' How many canvas pixels this occluder's claim is shrunk by this frame, to stay inside + ' what an approximate draw path may paint (see appendOccluderPolygons()). 0 by default. + ' + ' @param {Renderer} rendererObj + ' @param {integer} drawMode as returned by getOccluderDrawMode() + ' @return {float} + protected function getOccluderInset(rendererObj as Renderer, drawMode as integer) as float + return 0.0 + end function + + ' The draw mode this occluder draws in this frame, or -1 when it can't occlude this + ' frame: anything translucent, faded, wireframe, screen-aligned or not drawn because + ' its depth point is behind the camera, or no Camera3d. + ' + ' @param {Renderer} rendererObj + ' @return {integer} + protected function getOccluderDrawMode(rendererObj as Renderer) as integer + if not m.isOccluder or not m.isEnabled() or m.distanceFadeAlpha < 1 or m.drawable.alpha < 255 + return -1 + end if + ' Renderer skips drawing anything whose depth point is behind the camera + if m.negDistanceFromCamera >= 0 + return -1 + end if + cam = rendererObj.camera + if cam.name <> "Camera3d" + return -1 + end if + ' resolved, so a matchCamera occluder follows the camera's mode + drawMode = m.getActualDrawMode(cam) + if isWireFrameDrawMode(drawMode) or isScreenAlignedDrawMode(drawMode) + return -1 + end if + return drawMode + end function + ' Whether this object should actually be included in this build of the static ' geometry BSP tree (see Renderer.buildStaticGeometryTree()) - combines ' isEligibleForStaticBsp() (concrete type support) with a runtime check that the @@ -800,7 +991,7 @@ namespace BGE ' Whether this object was culled (skipped entirely) on the last frame drawn - either ' by the camera frustum, or by a deterministic draw failure (see ' isDeterministicDrawFailure()) that's being treated the same way. Game code can use - ' this (e.g. via GameEntity.isOnScreen()/Drawable.isOnScreen()) to skip its own + ' this (e.g. via GameEntity.isOnScreen()) to skip its own ' expensive per-frame work for an entity that isn't currently visible - see issue #75. ' ' @return {boolean} @@ -808,6 +999,14 @@ namespace BGE return m.lastFrameWasCulled end function + ' Whether this object was drawn, hidden behind something, or off camera as of the last + ' frame. unknown until it has been through a draw. See GameEntity.getVisibility(). + ' + ' @return {BGE.VisibilityState} + function getVisibility() as BGE.VisibilityState + return m.visibility + end function + end class end namespace diff --git a/src/source/engine/renderer/sceneObjects/SceneObject.spec.bs b/src/source/engine/renderer/sceneObjects/SceneObject.spec.bs index 19da5df1..141f1d38 100644 --- a/src/source/engine/renderer/sceneObjects/SceneObject.spec.bs +++ b/src/source/engine/renderer/sceneObjects/SceneObject.spec.bs @@ -163,6 +163,70 @@ namespace tests m.assertFalse(drawable.movedLastFrame(true)) end function + @describe("visibility") + + @it("is unknown before its first draw") + function _() + sceneObj = m.entity.addRectangle("vis", 40, 20).getSceneObjects()[0] + m.assertEqual(BGE.VisibilityState.unknown, sceneObj.getVisibility()) + end function + + @it("is visible after drawing") + function _() + m.entity.position = BGE.Math.VectorOps.create(0, 0, 0) + sceneObj = m.entity.addRectangle("vis", 40, 20).getSceneObjects()[0] + m.stepFrame(sceneObj) + m.assertEqual(BGE.VisibilityState.visible, sceneObj.getVisibility()) + end function + + @it("is offCamera when frustum-culled") + function _() + renderer = new BGE.Renderer(CreateObject("roBitmap", {width: 200, height: 100, alphaEnable: true})) + rect = new BGE.DrawableRectangle(m.entity, 40, 20) + sceneObj = rect.addToRenderer(renderer) + m.entity.position = BGE.Math.VectorOps.create(100, 50, 0) + m.entity.updateTransformationMatrix() + sceneObj.update(renderer.camera) + sceneObj.draw(renderer) + m.assertEqual(BGE.VisibilityState.visible, sceneObj.getVisibility()) + + m.entity.position = BGE.Math.VectorOps.create(100000, 0, 0) + m.entity.updateTransformationMatrix() + sceneObj.update(renderer.camera) + sceneObj.draw(renderer) + m.assertTrue(sceneObj.isCulled()) + m.assertEqual(BGE.VisibilityState.offCamera, sceneObj.getVisibility()) + end function + + @it("is offCamera when skipped behind the camera") + function _() + sceneObj = m.entity.addRectangle("vis", 40, 20).getSceneObjects()[0] + sceneObj.markSkippedBehindCamera(m.game.canvas.renderer.camera) + m.assertEqual(BGE.VisibilityState.offCamera, sceneObj.getVisibility()) + end function + + @it("is occluded after markSkippedOccluded and forces a canvas recompute on its next draw") + function _() + m.entity.position = BGE.Math.VectorOps.create(0, 0, 0) + rect = new BGE.DrawableRectangle(m.entity, 40, 20) + sceneObj = new tests.ExposedSceneObjectRectangle("occ", rect) + renderer = m.game.canvas.renderer + m.entity.updateTransformationMatrix() + sceneObj.update(renderer.camera) + sceneObj.draw(renderer) + m.assertTrue(sceneObj.hasCanvasPosition()) + + sceneObj.markSkippedOccluded(renderer.camera) + m.assertEqual(BGE.VisibilityState.occluded, sceneObj.getVisibility()) + m.assertFalse(sceneObj.hasCanvasPosition()) + m.assertFalse(sceneObj.isCulled()) + + sceneObj.update(renderer.camera) + sceneObj.draw(renderer) + m.assertTrue(sceneObj.hasCanvasPosition()) + m.assertEqual(BGE.VisibilityState.visible, sceneObj.getVisibility()) + end function + @describe("static misuse detection") ' Every frame these tests simulate calls m.entity.updateTransformationMatrix() before diff --git a/src/source/engine/renderer/sceneObjects/SceneObjectBillboard.bs b/src/source/engine/renderer/sceneObjects/SceneObjectBillboard.bs index 1d9b4a88..98b34a22 100644 --- a/src/source/engine/renderer/sceneObjects/SceneObjectBillboard.bs +++ b/src/source/engine/renderer/sceneObjects/SceneObjectBillboard.bs @@ -7,6 +7,9 @@ import "../../../utils/colors.bs" import "../../../utils/utils.bs" import "../../drawables/Drawable.bs" import "../../interfaces.bs" +import "../occlusion/ConvexInset.bs" +import "../occlusion/OcclusionTypes.bs" +import "../cameras/Camera3d.bs" import "../Renderer.bs" import "../RendererHelpers.bs" import "SceneObject.bs" @@ -25,6 +28,16 @@ namespace BGE didFastDraw as boolean end interface + ' Where drawRegionAsRotatedQuad() blits a region: its top left corner, size and rotation. + interface RotatedQuadBlit + x as float + y as float + width as float + height as float + ' radians, negated as CornerPoints.getAvgRotation() returns it + rotation as float + end interface + class SceneObjectBillboard extends SceneObject protected worldPoints as BGE.Math.CornerPoints = new BGE.Math.CornerPoints() @@ -110,8 +123,8 @@ namespace BGE m.tempBitmapRegion = invalid retval = m.drawToCanvas(rendererObj, drawMode) else - ' try to create a temp bitmap and draw it to the - m.tempBitmap = m.createTempBitmap(canvasBounds, rendererObj, drawMode, objectMoved) + ' try to create a temp bitmap and draw it to the canvas + m.tempBitmap = m.createTempBitmap(canvasBounds, rendererObj, drawMode, objectMoved and not m.drawsOccluderExactly()) if invalid <> m.tempBitmap retVal = rendererObj.drawObject(canvasBounds[0].x, canvasBounds[0].y, m.tempBitmapRegion, blitColor) end if @@ -148,6 +161,15 @@ namespace BGE return retVal end function + ' The cached temp bitmap was drawn for wherever this was last drawn, which may have + ' changed while it was hidden. + ' + ' @param {Camera} cameraObj the renderer's camera + override sub markSkippedOccluded(cameraObj as Camera) + super.markSkippedOccluded(cameraObj) + m.hasAccurateTempBitmap = false + end sub + ' Determine if we need to redraw directly to the canvas, instead of use previous temp bitmap ' ' @param {BGE.Renderer} rendererObj @@ -253,6 +275,7 @@ namespace BGE m.usedTransformedFastDrawLastFrame = m.canUseTransformedFastDraw() return not m.usedTransformedFastDrawLastFrame end if + m.usedTransformedFastDrawLastFrame = false return m.useTempBitmapMap[drawMode] end function @@ -372,13 +395,11 @@ namespace BGE ' @param {integer} color packed RGBA tint/fill color ' @return {boolean} true if the draw call succeeded protected function drawRegionAsRotatedQuad(rendererObj as BGE.Renderer, regionObj as roRegion, quad as BGE.Math.CornerPoints, color as integer) as boolean - ' drawRegion places the region by its *top left* corner, so that's what it has to be - ' given - passing the quad's center draws the sprite half its own size down and to the - ' right of where it belonged. + blit = m.getRotatedQuadBlit(quad) drawPos = quad.topLeft - totalScaleX = 1.0 * quad.getAvgWidth() / regionObj.getWidth() - totalScaleY = 1.0 * quad.getAvgHeight() / regionObj.getHeight() - theta = quad.getAvgRotation() + totalScaleX = 1.0 * blit.width / regionObj.getWidth() + totalScaleY = 1.0 * blit.height / regionObj.getHeight() + theta = blit.rotation ' Roku mirrors a region drawn at a negative scale around its draw position, so a ' mirrored quad still draws from topLeft, just flipped (issue #237). if quad.isMirroredOnCanvas() @@ -407,6 +428,54 @@ namespace BGE end function + ' Where drawRegionAsRotatedQuad() blits a region to match `quad`. drawRegion places a + ' region by its *top left* corner, so that's what it has to be given - passing the + ' quad's center draws the sprite half its own size down and to the right of where it + ' belonged. + ' + ' @param {BGE.Math.CornerPoints} quad + ' @return {RotatedQuadBlit} + protected function getRotatedQuadBlit(quad as BGE.Math.CornerPoints) as RotatedQuadBlit + return {x: quad.topLeft.x, y: quad.topLeft.y, width: quad.getAvgWidth(), height: quad.getAvgHeight(), rotation: quad.getAvgRotation()} + end function + + ' The canvas rectangle drawRegionAsRotatedQuad() paints for `quad`, in order around it. + ' + ' @param {BGE.Math.CornerPoints} quad + ' @return {BGE.Math.Vector[]} its 4 corners + protected function getRotatedQuadPaintedCorners(quad as BGE.Math.CornerPoints) as BGE.Math.Vector[] + blit = m.getRotatedQuadBlit(quad) + ' the blit's top edge runs at its rotation, y down + ux = cos(blit.rotation) + uy = -sin(blit.rotation) + ' the side edge runs toward the quad's bottom, which a mirrored quad has flipped + vx = -uy + vy = ux + anchorX = blit.x + anchorY = blit.y + if quad.isMirroredOnCanvas() + vx = uy + vy = -ux + if invalid = m.getMirroredBlitTransform(quad) + ' drawMirroredRegionRotated() blits from the top right corner back along the top edge + anchorX = quad.topRight.x + anchorY = quad.topRight.y + ux = -ux + uy = -uy + end if + end if + acrossX = ux * blit.width + acrossY = uy * blit.width + downX = vx * blit.height + downY = vy * blit.height + return [ + BGE.Math.VectorOps.create(anchorX, anchorY), + BGE.Math.VectorOps.create(anchorX + acrossX, anchorY + acrossY), + BGE.Math.VectorOps.create(anchorX + acrossX + downX, anchorY + acrossY + downY), + BGE.Math.VectorOps.create(anchorX + downX, anchorY + downY) + ] + end function + ' Blits a mirrored quad that is also rotated in two steps, since real hardware can't do ' both in one call (see getMirroredBlitTransform()): mirror the region into a scratch ' bitmap at a rotation of 0, then blit that at a positive scale. Mirroring it moves the @@ -643,6 +712,25 @@ namespace BGE return m.worldPoints.toArray() end function + ' A screen-aligned billboard doesn't draw in its own plane: directScaled is tested by the + ' camera-facing quad it draws, and the fixed-pixel-size modes are never tested. + protected override function getPositionsForOcclusionTest(cameraObj as Camera, drawMode as SceneObjectDrawMode) as BGE.Math.Vector[] + if not isScreenAlignedDrawMode(drawMode) + objType = m.type + if objType = SceneObjectType.Bitmap or objType = SceneObjectType.Rectangle or objType = SceneObjectType.Circle + ' the quad update() placed this frame, which is what it draws - getBoundingPoints() recomputes it + return m.worldPoints.toArray() + end if + return m.getBoundingPoints(cameraObj) + end if + size = m.getDirectModeCanvasSize() + ' a polygon or model has no width/height of its own + if drawMode <> BGE.SceneObjectDrawMode.directScaled or size.width = 0 or size.height = 0 + return invalid + end if + return m.getCameraFacingQuadWorldCorners(cameraObj) + end function + ' Computes this object's 4 world-space corners unconditionally, regardless of draw ' mode - the same pretranslation/size/transform math updateWorldPosition()'s ' oriented-mode branch already does, duplicated here rather than factored out @@ -834,10 +922,24 @@ namespace BGE ' @param {Renderer} rendererObj ' @return {boolean} true if every corner projected onto the canvas protected function updateCanvasPointsForCameraFacingQuad(rendererObj as Renderer) as boolean - cameraObj = rendererObj.camera - right = cameraObj.getRightVector() - up = cameraObj.getUpVector() + index = 0 + for each cornerWorldPoint in m.getCameraFacingQuadWorldCorners(rendererObj.camera) + canvasPoint = rendererObj.worldPointToCanvasPoint(cornerWorldPoint) + if invalid = canvasPoint + return false + end if + m.canvasPoints.setPointByIndex(index, canvasPoint) + index++ + end for + return true + end function + ' The world corners of the camera-facing quad directScaled draws - see + ' updateCanvasPointsForCameraFacingQuad(). + ' + ' @param {Camera} cameraObj + ' @return {BGE.Math.Vector[]} top left, top right, bottom left, bottom right + protected function getCameraFacingQuadWorldCorners(cameraObj as Camera) as BGE.Math.Vector[] ' getRightVector()/getUpVector() are roll-aware (rotated by the camera's own ' rollDegrees) - building the quad from them and then projecting through that ' same rolled camera necessarily cancels the roll back out, which is exactly what @@ -851,6 +953,9 @@ namespace BGE camera3d = cameraObj as Camera3d right = camera3d.getLevelRightVector() up = camera3d.getLevelUpVector() + else + right = cameraObj.getRightVector() + up = cameraObj.getUpVector() end if worldSize = m.getDirectModeCanvasSize() @@ -874,23 +979,12 @@ namespace BGE BGE.Math.VectorOps.scale(up, pretrans.y * totalScaleY)) topLeft = BGE.Math.VectorOps.add(m.worldPosition, anchorOffset) - cornerWorldPoints = [ + return [ topLeft, BGE.Math.VectorOps.add(topLeft, acrossWorld), BGE.Math.VectorOps.add(topLeft, downWorld), BGE.Math.VectorOps.add(BGE.Math.VectorOps.add(topLeft, acrossWorld), downWorld) ] - - index = 0 - for each cornerWorldPoint in cornerWorldPoints - canvasPoint = rendererObj.worldPointToCanvasPoint(cornerWorldPoint) - if invalid = canvasPoint - return false - end if - m.canvasPoints.setPointByIndex(index, canvasPoint) - index++ - end for - return true end function @@ -933,6 +1027,9 @@ namespace BGE ' @param {SceneObjectDrawMode} drawMode ' @return {TransformTempBitmapDetails} the details of the transformation or invalid if it cannot be done protected function attemptTransformTempBitmap(renderObj as Renderer, drawMode as SceneObjectDrawMode) as TransformTempBitmapDetails + if m.drawsOccluderExactly() + return invalid + end if if rodash.isInvalid(m.tempBitmap) or rodash.isInvalid(m.tempBitmapRegion) or rodash.isInvalid(m.limitPointMap) or not m.usesTempBitmap(drawMode) return invalid end if @@ -1010,7 +1107,16 @@ namespace BGE ' @param {SceneObjectDrawMode} drawMode - some drawModes explicitly should draw the backface ' @return {boolean} true if we should not draw protected function doNotDrawBecauseBackFace(drawMode as SceneObjectDrawMode) as boolean - if m.isMirror + return m.isCulledAsBackFace(m.isMirror, drawMode) + end function + + ' Whether a quad is skipped as a back face in `drawMode`, given whether it faces away. + ' + ' @param {boolean} facesAway + ' @param {SceneObjectDrawMode} drawMode + ' @return {boolean} + private function isCulledAsBackFace(facesAway as boolean, drawMode as SceneObjectDrawMode) as boolean + if facesAway if drawMode = SceneObjectDrawMode.orientedDrawBackFace or drawMode = SceneObjectDrawMode.wireFrameDrawBackFace return false end if @@ -1019,6 +1125,142 @@ namespace BGE return false end function + ' Adds this quad's canvas polygon when it qualifies as an occluder this frame and all + ' four corners project - see SceneObject.appendOccluderPolygons(). + ' + ' @param {Renderer} rendererObj + ' @param {BGE.OccluderPolygon[]} out + override function appendOccluderPolygons(rendererObj as Renderer, out as BGE.OccluderPolygon[]) as void + drawMode = m.getOccluderDrawMode(rendererObj) + if drawMode < 0 + return + end if + ' the same facing test findCanvasPosition() feeds the draw path's backface cull + facesAway = invalid <> m.surfaceNormal and not isNormalFacingCamera(rendererObj, m.worldPoints.getCenter(), m.surfaceNormal) + if m.isCulledAsBackFace(facesAway, drawMode as BGE.SceneObjectDrawMode) + return + end if + ' projected the way updateCanvasPosition() will project it for this frame's draw + cam = rendererObj.camera + canvasQuad = new BGE.Math.CornerPoints() + depth = 0.0 + index = 0 + for each corner in m.worldPoints.toArray() + canvasPoint = rendererObj.worldPointToCanvasPoint(corner) + if invalid = canvasPoint + return + end if + canvasQuad.setPointByIndex(index, canvasPoint) + depth = BGE.Math.Max(depth, cam.distanceFromCameraFront(corner)) + index++ + end for + polygon = m.getOccluderClaim(rendererObj, drawMode as BGE.SceneObjectDrawMode, canvasQuad, depth) + if invalid <> polygon + out.push(polygon) + end if + end function + + ' The part of this frame's canvas quad the draw is guaranteed to paint: the quad, shrunk + ' by the worst error of any approximate draw path that may run this frame. + ' + ' @param {Renderer} rendererObj + ' @param {SceneObjectDrawMode} drawMode + ' @param {BGE.Math.CornerPoints} canvasQuad this frame's projected corners + ' @param {float} depth the farthest corner's distance from the camera + ' @return {BGE.OccluderPolygon} invalid if nothing is guaranteed + protected function getOccluderClaim(rendererObj as Renderer, drawMode as SceneObjectDrawMode, canvasQuad as BGE.Math.CornerPoints, depth as float) as BGE.OccluderPolygon + return m.newOccluderPolygon(canvasQuad.toClockwiseOutlineArray(), depth, m.getOccluderInset(rendererObj, drawMode)) + end function + + ' How far this frame's draw may fall short of the exact quad: the temp-bitmap reuse + ' threshold when that reuse may run, plus any coarse fill. + ' + ' @param {Renderer} rendererObj + ' @param {integer} drawMode + ' @return {float} + protected override function getOccluderInset(rendererObj as Renderer, drawMode as integer) as float + ' reuse and coarse fills only happen while the object moves relative to the camera + if not m.shrinksOccluderClaim() or not m.objMovedInRelationToCamera(rendererObj.camera) + return 0.0 + end if + inset = m.getCoarseFillInset(rendererObj, drawMode) + if m.mayReuseTransformedTempBitmap(drawMode) + ' the reused bitmap may itself hold a coarse fill + inset += m.getTempBitmapThreshold(drawMode as SceneObjectDrawMode) + end if + return inset + end function + + ' How far a coarse fill drawn while moving may fall short of the exact shape. 0 for + ' a type that never draws one. + ' + ' @param {Renderer} rendererObj + ' @param {integer} drawMode + ' @return {float} + protected function getCoarseFillInset(rendererObj as Renderer, drawMode as integer) as float + return 0.0 + end function + + ' Whether attemptTransformTempBitmap() could reuse the cached temp bitmap this frame, + ' judged from what's cached before the draw. + ' + ' @param {integer} drawMode + ' @return {boolean} + protected function mayReuseTransformedTempBitmap(drawMode as integer) as boolean + return m.useTempBitmapMap[drawMode] and invalid <> m.tempBitmap and invalid <> m.tempBitmapRegion and invalid <> m.limitPointMap + end function + + ' Projects a convex world-space outline to an occluder polygon. + ' + ' @param {Renderer} rendererObj + ' @param {BGE.Math.Vector[]} worldOutline the polygon's vertices, in order around it + ' @param {float} [inset=0] how far to shrink it, in canvas pixels + ' @return {BGE.OccluderPolygon} invalid if any vertex doesn't project, or the inset collapses it + protected function projectOccluderPolygon(rendererObj as Renderer, worldOutline as BGE.Math.Vector[], inset = 0 as float) as BGE.OccluderPolygon + projected = [] as float[] + if not (rendererObj.camera as Camera3d).projectPointsWithDepth(worldOutline, projected) + return invalid + end if + xs = [] as float[] + ys = [] as float[] + depth = 0.0 + for i = 0 to projected.count() - 1 step 3 + xs.push(projected[i] * 1.0) + ys.push(projected[i + 1] * 1.0) + depth = BGE.Math.Max(depth, projected[i + 2]) + end for + return m.newOccluderPolygonFromXY(xs, ys, depth, inset) + end function + + ' An occluder polygon from a convex canvas outline, shrunk by `inset`. + ' + ' @param {BGE.Math.Vector[]} canvasOutline vertices in order around it + ' @param {float} depth the farthest vertex's distance from the camera + ' @param {float} inset how far to shrink it, in canvas pixels + ' @return {BGE.OccluderPolygon} invalid if the inset collapses it + protected function newOccluderPolygon(canvasOutline as BGE.Math.Vector[], depth as float, inset as float) as BGE.OccluderPolygon + xs = [] as float[] + ys = [] as float[] + for each point in canvasOutline + xs.push(point.x * 1.0) + ys.push(point.y * 1.0) + end for + return m.newOccluderPolygonFromXY(xs, ys, depth, inset) + end function + + ' newOccluderPolygon() from separate x and y lists, which it may keep. + protected function newOccluderPolygonFromXY(xs as float[], ys as float[], depth as float, inset as float) as BGE.OccluderPolygon + if inset > 0 + shrunk = BGE.Occlusion.insetConvexPolygon(xs, ys, inset) + if invalid = shrunk + return invalid + end if + xs = shrunk.xs + ys = shrunk.ys + end if + return {xs: xs, ys: ys, depth: depth, area: BGE.Occlusion.getPolygonArea(xs, ys)} + end function + ' A backface cull and an in-frustum-but-entirely-off-canvas rejection are both ' deterministic for a billboard: neither depends on anything beyond this object's own ' orientation/position relative to the camera and canvas, both of which are exactly diff --git a/src/source/engine/renderer/sceneObjects/SceneObjectImage.bs b/src/source/engine/renderer/sceneObjects/SceneObjectImage.bs index e8e42f50..43d763ab 100644 --- a/src/source/engine/renderer/sceneObjects/SceneObjectImage.bs +++ b/src/source/engine/renderer/sceneObjects/SceneObjectImage.bs @@ -1,6 +1,8 @@ import "../../../math/vector.bs" import "../../drawables/Image.bs" import "../cameras/Camera.bs" +import "../occlusion/OcclusionTypes.bs" +import "../../../math/CornerPoints.bs" import "../Renderer.bs" import "../RendererHelpers.bs" import "SceneObject.bs" @@ -64,6 +66,9 @@ namespace BGE ' @param {SceneObjectDrawMode} drawMode ' @return {boolean} protected override function isEligibleForFastDrawCheck(drawMode as SceneObjectDrawMode) as boolean + if m.drawsOccluderExactly() + return false + end if return drawMode = SceneObjectDrawMode.oriented or drawMode = SceneObjectDrawMode.orientedDrawBackFace end function @@ -86,6 +91,30 @@ namespace BGE return true end function + ' When this frame's quad takes the fast path, the claim is the rectangle that path + ' paints, 1px in for rounding - see SceneObjectBillboard.getOccluderClaim(). + ' + ' @param {Renderer} rendererObj + ' @param {SceneObjectDrawMode} drawMode + ' @param {BGE.Math.CornerPoints} canvasQuad + ' @param {float} depth + ' @return {BGE.OccluderPolygon} + protected override function getOccluderClaim(rendererObj as Renderer, drawMode as SceneObjectDrawMode, canvasQuad as BGE.Math.CornerPoints, depth as float) as BGE.OccluderPolygon + ' the same test usesTempBitmap() makes at draw time, with the same tolerances + settings = rendererObj.qualitySettings + if m.shrinksOccluderClaim() and m.isEligibleForFastDrawCheck(drawMode) and canvasQuad.isApproximatelyRotatedRectangle(settings.fastDrawParallelogramTolerance, settings.fastDrawPerpendicularityTolerance) + return m.newOccluderPolygon(m.getRotatedQuadPaintedCorners(canvasQuad), depth, 1) + end if + return super.getOccluderClaim(rendererObj, drawMode, canvasQuad, depth) + end function + + ' Opaque, so it can hide what's behind it (see Drawable.isOccluder). + ' + ' @return {boolean} + override function isEligibleOccluderType() as boolean + return true + end function + end class end namespace diff --git a/src/source/engine/renderer/sceneObjects/SceneObjectModel.bs b/src/source/engine/renderer/sceneObjects/SceneObjectModel.bs index b8ad49e3..8665ffb4 100644 --- a/src/source/engine/renderer/sceneObjects/SceneObjectModel.bs +++ b/src/source/engine/renderer/sceneObjects/SceneObjectModel.bs @@ -3,6 +3,7 @@ import "../../../math/Matrix44.bs" import "../../../math/vector.bs" import "../../../utils/colors.bs" import "../../drawables/Model3d.bs" +import "../occlusion/OccluderPanels.bs" import "../Renderer.bs" import "../RendererHelpers.bs" import "SceneObject.bs" @@ -14,6 +15,16 @@ namespace BGE drawable as DrawableModel + ' Convex opaque panels merged from the model's faces, in model-local space. Built + ' when the model is registered as an occluder (see prepareOccluder()), and again + ' after Drawable.invalidateGeometry(). + occluderPanels as BGE.OccluderPanel[] = [] + ' occluderPanels in world space, rebuilt when the model moves. + private occluderPanelsWorld as BGE.OccluderPanel[] = [] + private occluderPanelsWorldValid as boolean = false + ' The drawable's geometryVersion occluderPanels were built from. + private occluderPanelsGeometryVersion as integer = -1 + private modelWorldBounds as BGE.Math.Vector[] = [] private modelWorldFaces as BGE.Model3dFace[] = [] @@ -165,7 +176,8 @@ namespace BGE ' @param {BGE.Model3dFace} face ' @param {float} modelOffsetX the model's own canvas-bounds top-left, negated (see drawToTempBitmap) ' @param {float} modelOffsetY - ' @param {boolean} allowFastDraw forwarded to drawTriangleTo for an untextured face - see Renderer.drawTriangleTo + ' @param {boolean} allowFastDraw whether the model is moving (see mayReuseFaceRaster()), and + ' forwarded to drawTriangleTo for an untextured face - see Renderer.drawTriangleTo ' @return {boolean} true if something was actually drawn private function drawFaceWithReuse(rendererObj as BGE.Renderer, tempBitmap as ifDraw2d, face as BGE.Model3dFace, modelOffsetX as float, modelOffsetY as float, allowFastDraw as boolean) as boolean bounds = BGE.Math.getBounds(face.vertices) @@ -190,7 +202,7 @@ namespace BGE ' to the LAST redraw's footprint, not this frame's). Require the cache to already be ' big enough before cropping it to this size, or a reused frame could ask ' getCroppedFaceRegion() for a region larger than its backing bitmap. - if invalid <> cache.bitmap and cache.bitmap.GetWidth() >= width and cache.bitmap.GetHeight() >= height and cache.staleFrameCount < m.drawable.maxStaleFrames and not m.qualityChangedThisDraw and not m.hasFaceDriftedTooFar(face.vertices, cache.triangleAtCapture) + if invalid <> cache.bitmap and cache.bitmap.GetWidth() >= width and cache.bitmap.GetHeight() >= height and cache.staleFrameCount < m.drawable.maxStaleFrames and m.mayReuseFaceRaster(allowFastDraw) and not m.qualityChangedThisDraw and not m.hasFaceDriftedTooFar(face.vertices, cache.triangleAtCapture) cache.staleFrameCount++ m.lastDrawWasFullyFresh = false return rendererObj.drawObjectTo(tempBitmap, faceOffsetX, faceOffsetY, m.getCroppedFaceRegion(cache.bitmap, width, height)) @@ -221,6 +233,22 @@ namespace BGE return rendererObj.drawObjectTo(tempBitmap, faceOffsetX, faceOffsetY, m.getCroppedFaceRegion(faceBitmap, width, height)) end function + ' Whether a face may reuse its cached raster this draw. A fastShrink occluder only reuses + ' while moving, so a still one ends up exact and can claim its exact outline; an exact + ' occluder never reuses. + ' + ' @param {boolean} moving whether the model moved relative to the camera + ' @return {boolean} + private function mayReuseFaceRaster(moving as boolean) as boolean + if not m.isOccluder + return true + end if + if m.occluderDrawPolicy = BGE.OccluderDrawPolicy.exact + return false + end if + return moving or m.occluderDrawPolicy <> BGE.OccluderDrawPolicy.fastShrink + end function + ' Crops `bitmap` (which may be larger than `width`x`height` due to grow-only reuse - ' see RendererHelpers.growOrClearBitmap) down to this frame's actual face footprint, ' so a blit never carries stale/blank capacity left over from a previous, larger @@ -288,6 +316,10 @@ namespace BGE protected override function updateWorldPosition(drawMode as SceneObjectDrawMode) as boolean + if m.isOccluder and m.occluderPanelsGeometryVersion <> m.drawable.geometryVersion + m.prepareOccluder() + end if + m.occluderPanelsWorldValid = false m.modelWorldFaces = [] m.worldPosition = m.drawable.getWorldPosition() m.modelWorldBounds = [] @@ -328,8 +360,11 @@ namespace BGE override function getPositionForCameraDistance(drawMode as SceneObjectDrawMode) as BGE.Math.Vector - if drawMode = BGE.SceneObjectDrawMode.oriented - return m.worldPoints.getCenter() + ' worldPoints is never filled for a model, so use the centre of its world bounds + if not isScreenAlignedDrawMode(drawMode) and m.modelWorldBounds.Count() = 2 + lo = m.modelWorldBounds[0] + hi = m.modelWorldBounds[1] + return BGE.Math.VectorOps.create((lo.x + hi.x) / 2, (lo.y + hi.y) / 2, (lo.z + hi.z) / 2) end if return m.worldPosition end function @@ -378,7 +413,7 @@ namespace BGE thisFaceIndex = worldFaceIndex worldFaceIndex++ canvasPoints = [] as BGE.Math.Vector[] - if isBackFaceDrawMode(drawMode) or isNormalFacingCamera(rendererObj, worldFace.vertices[0], worldFace.normal) + if isBackFaceDrawMode(drawMode) or isOutwardNormalFacingCamera(rendererObj, worldFace.vertices[0], worldFace.normal) negDistances = [] for each vert in worldFace.vertices @@ -402,7 +437,7 @@ namespace BGE canvasFace = BGE.Model3dFaceOps.create(canvasPoints) canvasFace.Texture = worldFace.Texture canvasFace.color = worldFace.color - canvasFace.brightness = isShaded? BGE.Math.Max(getDotProductFromSurfaceToCamera(rendererObj, worldFace.vertices[0], worldFace.normal), m.drawable.ambientBrightness) : 1 + canvasFace.brightness = isShaded? BGE.Math.Max(getOutwardNormalFacingAmount(rendererObj, worldFace.vertices[0], worldFace.normal), m.drawable.ambientBrightness) : 1 canvasFace.priority = -BGE.Math.arrayMax(negDistances) canvasFace.faceIndex = thisFaceIndex m.modelCanvasFaces.Push(canvasFace) @@ -517,6 +552,102 @@ namespace BGE override function drawPrimitive(rendererObj as Renderer, index as integer) as boolean return m.drawFaceToCanvas(rendererObj, m.modelCanvasFaces[index], m.lastDrawMode as SceneObjectDrawMode) end function + + ' A model hides what's behind it through its merged opaque panels (see Drawable.isOccluder). + ' + ' @return {boolean} + override function isEligibleOccluderType() as boolean + return true + end function + + ' Builds and caches the model's occluder panels. + override sub prepareOccluder() + m.occluderPanels = BGE.Occlusion.buildModelPanels(m.drawable.model.faces) + m.occluderPanelsGeometryVersion = m.drawable.geometryVersion + m.occluderPanelsWorldValid = false + end sub + + ' Adds one polygon per panel the draw path draws this frame: facing the camera (or + ' any panel in a *DrawBackFace mode), with every vertex projecting. While moving, each + ' panel is shrunk by getOccluderInset(), and a panel with a vertex inside it is left out, + ' since a tiny face there may be skipped. + ' + ' @param {Renderer} rendererObj + ' @param {BGE.OccluderPolygon[]} out + override function appendOccluderPolygons(rendererObj as Renderer, out as BGE.OccluderPolygon[]) as void + drawMode = m.getOccluderDrawMode(rendererObj) + if drawMode < 0 + return + end if + inset = m.getOccluderInset(rendererObj, drawMode) + drawBackFaces = isBackFaceDrawMode(drawMode as BGE.SceneObjectDrawMode) + if not m.occluderPanelsWorldValid + m.occluderPanelsWorld = m.getOccluderPanelsInWorld() + m.occluderPanelsWorldValid = true + end if + for each panel in m.occluderPanelsWorld + if inset > 0 and panel.hasInnerVertices + continue for + end if + if not drawBackFaces and not isOutwardNormalFacingCamera(rendererObj, panel.vertices[0], panel.normal) + continue for + end if + polygon = m.projectOccluderPolygon(rendererObj, panel.vertices, inset) + if invalid <> polygon + out.push(polygon) + end if + end for + end function + + private function getOccluderPanelsInWorld() as BGE.OccluderPanel[] + worldPanels = [] as BGE.OccluderPanel[] + for each panel in m.occluderPanels + worldVertices = [] as BGE.Math.Vector[] + for each vertex in panel.vertices + worldVertices.push(BGE.Math.Matrix44.multVecMatrix(vertex, m.transformationMatrix)) + end for + ' world-space normal built like Model3dFaceOps.resetNormal(), as updateCanvasPosition() tests it + normal = BGE.Math.VectorOps.crossProduct(BGE.Math.VectorOps.subtract(worldVertices[1], worldVertices[0]), BGE.Math.VectorOps.subtract(worldVertices[2], worldVertices[0])) + BGE.Math.VectorOps.normalize(normal) + worldPanels.push({vertices: worldVertices, normal: normal, hasInnerVertices: panel.hasInnerVertices}) + end for + return worldPanels + end function + + ' How far a moving model's draw may fall short of its exact faces: the larger of the + ' whole-model stretch threshold (when that may run) and a reused face raster's error, + ' plus any coarse fill - and never less than a skippable tiny face's reach. + ' + ' @param {Renderer} rendererObj + ' @param {integer} drawMode + ' @return {float} + protected override function getOccluderInset(rendererObj as Renderer, drawMode as integer) as float + ' every approximation is only taken while moving (see drawFaceWithReuse()) + if not m.shrinksOccluderClaim() or not m.objMovedInRelationToCamera(rendererObj.camera) + return 0.0 + end if + inset = 0.0 + if m.drawable.maxStaleFrames > 0 + ' a reused raster sits at this frame's bounding box, so each vertex can be off by + ' its own drift plus the box corner's + inset = m.drawable.maxFaceDriftDistance * (1 + Sqr(2)) + end if + if m.mayReuseTransformedTempBitmap(drawMode) + inset = BGE.Math.Max(inset, m.getTempBitmapThreshold(drawMode as SceneObjectDrawMode)) + end if + inset += rendererObj.getQuickDrawFillError() + ' a skipped face lies within its own bounding box's diagonal of the panel's edge + return BGE.Math.Max(inset, rendererObj.qualitySettings.triangleSkipSize * Sqr(2)) + end function + + ' Also needs the cached bitmap to be every face freshly drawn - see attemptTransformTempBitmap(). + ' + ' @param {integer} drawMode + ' @return {boolean} + protected override function mayReuseTransformedTempBitmap(drawMode as integer) as boolean + return m.lastDrawWasFullyFresh and super.mayReuseTransformedTempBitmap(drawMode) + end function + end class end namespace diff --git a/src/source/engine/renderer/sceneObjects/SceneObjectModel.spec.bs b/src/source/engine/renderer/sceneObjects/SceneObjectModel.spec.bs index 9faa83dc..fb383251 100644 --- a/src/source/engine/renderer/sceneObjects/SceneObjectModel.spec.bs +++ b/src/source/engine/renderer/sceneObjects/SceneObjectModel.spec.bs @@ -411,6 +411,103 @@ namespace tests m.assertTrue(totalDrawCalls > 0) end function + @describe("oriented depth") + + private function assertDepthFromCentre(drawMode as BGE.SceneObjectDrawMode) + camera = new BGE.Camera3d() + camera.position = BGE.Math.VectorOps.create(0, 0, -500) + camera.setTarget(BGE.Math.VectorOps.create(0, 0, 0)) + depthRenderer = new BGE.Renderer(m.bitmap, m.game, camera) + m.entity.position = BGE.Math.VectorOps.create(0, 0, 300) + face = BGE.Model3dFaceOps.create([ + BGE.Math.VectorOps.create(-10, -10, -10), + BGE.Math.VectorOps.create(10, -10, -10), + BGE.Math.VectorOps.create(0, 10, 10) + ]) + drawableModel = new BGE.DrawableModel(m.entity, new BGE.Model3d([face])) + drawableModel.drawMode = drawMode + sceneObj = drawableModel.addToRenderer(depthRenderer) + m.entity.updateTransformationMatrix() + depthRenderer.setupCameraForFrame() + depthRenderer.render() + m.assertTrue(abs(sceneObj.getDepthPosition().z - 300) < 1) + end function + + @it("measures oriented depth from the object's own centre, not the world origin") + function _() + m.assertDepthFromCentre(BGE.SceneObjectDrawMode.oriented) + end function + + @it("measures solid depth from the object's own centre, not the world origin") + function _() + m.assertDepthFromCentre(BGE.SceneObjectDrawMode.solid) + end function + + @describe("face culling") + + ' A cube centred on the origin, each face wound counter-clockwise seen from outside + ' (the .obj/.stl convention), viewed in `drawMode` from (0, 0, 500). + private function renderOutwardCube(drawMode as BGE.SceneObjectDrawMode) as BGE.SceneObjectModel + faces = [] + quads = [ + [[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]], + [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], + [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]], + [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]], + [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]], + [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] + ] + for each q in quads + for each tri in [[q[0], q[1], q[2]], [q[0], q[2], q[3]]] + verts = [] as BGE.Math.Vector[] + for each p in tri + verts.push(BGE.Math.VectorOps.create(p[0] * 100 - 50, p[1] * 100 - 50, p[2] * 100 - 50)) + end for + faces.push(BGE.Model3dFaceOps.create(verts)) + end for + end for + camera = new BGE.Camera3d() + camera.position = BGE.Math.VectorOps.create(0, 0, 500) + camera.setTarget(BGE.Math.VectorOps.create(0, 0, 0)) + camera.drawDistanceFadeFraction = 0 + cubeRenderer = new BGE.Renderer(CreateObject("roBitmap", {width: 320, height: 240, alphaEnable: true}), m.game, camera) + m.entity.position = BGE.Math.VectorOps.create(0, 0, 0) + drawableModel = new BGE.DrawableModel(m.entity, new BGE.Model3d(faces)) + drawableModel.drawMode = drawMode + sceneObj = drawableModel.addToRenderer(cubeRenderer) as BGE.SceneObjectModel + m.entity.updateTransformationMatrix() + cubeRenderer.setupCameraForFrame() + cubeRenderer.render() + return sceneObj + end function + + @it("draws only the outside faces nearest the camera in solid mode") + function _() + allFaces = m.renderOutwardCube(BGE.SceneObjectDrawMode.solidDrawBackFace) + m.assertEqual(allFaces.getPrimitiveCount(), 12) + nearest = allFaces.getPrimitiveDepth(0) + farthest = nearest + for i = 0 to 11 + nearest = BGE.Math.Max(nearest, allFaces.getPrimitiveDepth(i)) + farthest = BGE.Math.Min(farthest, allFaces.getPrimitiveDepth(i)) + end for + m.assertTrue(nearest - farthest > 90, "the -z face is 100 farther than the +z face") + + sceneObj = m.renderOutwardCube(BGE.SceneObjectDrawMode.solid) + m.assertEqual(sceneObj.getPrimitiveCount(), 2) + for i = 0 to 1 + ' the +z face, not the -z face 100 units farther away + m.assertTrue(abs(sceneObj.getPrimitiveDepth(i) - nearest) < 1, "face " + i.toStr() + " depth " + sceneObj.getPrimitiveDepth(i).toStr()) + m.assertTrue(sceneObj.getFaceBrightness(i) > 0.9, "face " + i.toStr() + " brightness " + sceneObj.getFaceBrightness(i).toStr()) + end for + end function + + @it("culls faces the same way in oriented mode") + function _() + sceneObj = m.renderOutwardCube(BGE.SceneObjectDrawMode.oriented) + m.assertEqual(sceneObj.getPrimitiveCount(), 2) + end function + end class end namespace diff --git a/src/source/engine/renderer/sceneObjects/SceneObjectParticle.bs b/src/source/engine/renderer/sceneObjects/SceneObjectParticle.bs index 12311da6..41039cc7 100644 --- a/src/source/engine/renderer/sceneObjects/SceneObjectParticle.bs +++ b/src/source/engine/renderer/sceneObjects/SceneObjectParticle.bs @@ -58,6 +58,11 @@ namespace BGE return BGE.Math.getBounds(positions) end function + ' Particles are drawn with a size around each position, which the bounds don't cover. + override function canBeOccluded() as boolean + return false + end function + protected override function performDraw(rendererObj as BGE.Renderer, drawMode as SceneObjectDrawMode) as boolean didDrawAny = false diff --git a/src/source/engine/renderer/sceneObjects/SceneObjectPolygon.bs b/src/source/engine/renderer/sceneObjects/SceneObjectPolygon.bs index 1603119a..b82fb4dd 100644 --- a/src/source/engine/renderer/sceneObjects/SceneObjectPolygon.bs +++ b/src/source/engine/renderer/sceneObjects/SceneObjectPolygon.bs @@ -86,8 +86,34 @@ namespace BGE override function getPositionForCameraDistance(drawMode as SceneObjectDrawMode) as BGE.Math.Vector - if drawMode = BGE.SceneObjectDrawMode.oriented - return m.worldPoints.getCenter() + ' worldPoints is never filled for a polygon, so use the centre of its points' bounds + if not isScreenAlignedDrawMode(drawMode) and m.polygonWorldPoints.Count() > 0 + first = m.polygonWorldPoints[0] + minX = first.x + maxX = minX + minY = first.y + maxY = minY + minZ = first.z + maxZ = minZ + ' scalar min/max, so a depth recompute allocates one vector, not two per point + for each point in m.polygonWorldPoints + if point.x < minX + minX = point.x + else if point.x > maxX + maxX = point.x + end if + if point.y < minY + minY = point.y + else if point.y > maxY + maxY = point.y + end if + if point.z < minZ + minZ = point.z + else if point.z > maxZ + maxZ = point.z + end if + end for + return BGE.Math.VectorOps.create((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2) end if return m.worldPosition end function diff --git a/src/source/engine/renderer/sceneObjects/SceneObjectPolygon.spec.bs b/src/source/engine/renderer/sceneObjects/SceneObjectPolygon.spec.bs index 33011f6d..a4618951 100644 --- a/src/source/engine/renderer/sceneObjects/SceneObjectPolygon.spec.bs +++ b/src/source/engine/renderer/sceneObjects/SceneObjectPolygon.spec.bs @@ -98,6 +98,38 @@ namespace tests m.assertTrue(m.drawOneFrame(sceneObj) > 0) end function + @describe("oriented depth") + + private function assertDepthFromCentre(drawMode as BGE.SceneObjectDrawMode) + camera = new BGE.Camera3d() + camera.position = BGE.Math.VectorOps.create(0, 0, -500) + camera.setTarget(BGE.Math.VectorOps.create(0, 0, 0)) + depthRenderer = new BGE.Renderer(m.bitmap, m.game, camera) + m.entity.position = BGE.Math.VectorOps.create(0, 0, 300) + polygon = new BGE.DrawablePolygon(m.entity, [ + BGE.Math.VectorOps.create(-20, -20, 0), + BGE.Math.VectorOps.create(20, -20, 0), + BGE.Math.VectorOps.create(20, 20, 0), + BGE.Math.VectorOps.create(-20, 20, 0) + ]) + polygon.drawMode = drawMode + sceneObj = polygon.addToRenderer(depthRenderer) + m.entity.updateTransformationMatrix() + depthRenderer.setupCameraForFrame() + depthRenderer.render() + m.assertTrue(abs(sceneObj.getDepthPosition().z - 300) < 1) + end function + + @it("measures oriented depth from the object's own centre, not the world origin") + function _() + m.assertDepthFromCentre(BGE.SceneObjectDrawMode.oriented) + end function + + @it("measures solid depth from the object's own centre, not the world origin") + function _() + m.assertDepthFromCentre(BGE.SceneObjectDrawMode.solid) + end function + end class end namespace diff --git a/src/source/engine/renderer/sceneObjects/SceneObjectRectangle.bs b/src/source/engine/renderer/sceneObjects/SceneObjectRectangle.bs index 7fbb3d49..270f5203 100644 --- a/src/source/engine/renderer/sceneObjects/SceneObjectRectangle.bs +++ b/src/source/engine/renderer/sceneObjects/SceneObjectRectangle.bs @@ -135,7 +135,7 @@ namespace BGE end if worked = false ' Drawn fresh every frame, so a coarse fill only lasts while it moves - allowQuickDraw = m.objMovedInRelationToCamera(rendererObj.camera) + allowQuickDraw = m.objMovedInRelationToCamera(rendererObj.camera) and not m.drawsOccluderExactly() bandAcrossY = rendererObj.shouldBandAcrossY(m.canvasPoints.toArray()) for each triangle in m.getQuadTriangles(m.canvasPoints) worked = rendererObj.drawTriangle(triangle, 0, 0, fillColor, allowQuickDraw, bandAcrossY) or worked @@ -210,6 +210,54 @@ namespace BGE return true end function + ' Opaque, so it can hide what's behind it (see Drawable.isOccluder). + ' + ' @return {boolean} + override function isEligibleOccluderType() as boolean + return true + end function + + ' While moving, a fill may use coarse bands up to the quality level's band step. + ' + ' @param {Renderer} rendererObj + ' @param {integer} drawMode + ' @return {float} + protected override function getCoarseFillInset(rendererObj as Renderer, drawMode as integer) as float + return rendererObj.getQuickDrawFillError() + end function + + ' Claims nothing while moving if either of the quad's triangles is small enough to be + ' skipped by a quick draw (see Renderer.drawTriangleTo()). + ' + ' @param {Renderer} rendererObj + ' @param {SceneObjectDrawMode} drawMode + ' @param {BGE.Math.CornerPoints} canvasQuad + ' @param {float} depth + ' @return {BGE.OccluderPolygon} + protected override function getOccluderClaim(rendererObj as Renderer, drawMode as SceneObjectDrawMode, canvasQuad as BGE.Math.CornerPoints, depth as float) as BGE.OccluderPolygon + if m.shrinksOccluderClaim() and m.objMovedInRelationToCamera(rendererObj.camera) + skipSize = rendererObj.qualitySettings.triangleSkipSize + for each triangle in m.getQuadTriangles(canvasQuad) + bounds = BGE.Math.getBounds(triangle) + if bounds[1].x - bounds[0].x < skipSize and bounds[1].y - bounds[0].y < skipSize + return invalid + end if + end for + end if + return super.getOccluderClaim(rendererObj, drawMode, canvasQuad, depth) + end function + + ' An outline-only rectangle hides nothing. + ' + ' @param {Renderer} rendererObj + ' @return {integer} + protected override function getOccluderDrawMode(rendererObj as Renderer) as integer + if not m.drawable.filled + return -1 + end if + return super.getOccluderDrawMode(rendererObj) + end function + end class end namespace diff --git a/src/source/engine/renderer/sceneObjects/SceneObjectTestDoubles.spec.bs b/src/source/engine/renderer/sceneObjects/SceneObjectTestDoubles.spec.bs index accd193c..bb941446 100644 --- a/src/source/engine/renderer/sceneObjects/SceneObjectTestDoubles.spec.bs +++ b/src/source/engine/renderer/sceneObjects/SceneObjectTestDoubles.spec.bs @@ -63,6 +63,11 @@ namespace tests super(name, drawableObj) end sub + ' Whether draw() has a canvas position cached from an earlier frame. + function hasCanvasPosition() as boolean + return m["hasValidCanvasPosition"] + end function + function fillTempBitmap(rendererObj as BGE.Renderer, tempBitmap as ifDraw2d, allowFastDraw as boolean) as BGE.TempBitmapDrawResult return m.drawToTempBitmap(rendererObj, tempBitmap, BGE.Math.VectorOps.create(), BGE.SceneObjectDrawMode.oriented, allowFastDraw) end function diff --git a/src/source/engine/renderer/sceneObjects/SceneObjectText.bs b/src/source/engine/renderer/sceneObjects/SceneObjectText.bs index 37810bc9..5ea58ee7 100644 --- a/src/source/engine/renderer/sceneObjects/SceneObjectText.bs +++ b/src/source/engine/renderer/sceneObjects/SceneObjectText.bs @@ -36,6 +36,11 @@ namespace BGE return `${m.name}_${m.lastTextKey}` end function + ' Text's size is only known once it draws, so it's never judged hidden. + override function canBeOccluded() as boolean + return false + end function + protected override function getRegionWithIdToDraw(rendererObj as BGE.Renderer) as BGE.RendererHelpers.RegionWithId drawableRegion = BGE.RendererHelpers.createRegionWithId(m.drawable.getTextImage(), m.getUniqueRegionName()) return drawableRegion