OpenSceneGraph 0.1.2__cp313-cp313-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. OpenSceneGraph/OpenThreads.lib +0 -0
  2. OpenSceneGraph/_OpenSceneGraph.cp313-win_amd64.pyd +0 -0
  3. OpenSceneGraph/__init__.py +52 -0
  4. OpenSceneGraph/aipython/00-index.md +44 -0
  5. OpenSceneGraph/aipython/01-core.md +255 -0
  6. OpenSceneGraph/aipython/02-inspect.md +81 -0
  7. OpenSceneGraph/aipython/03-headless-frames.md +149 -0
  8. OpenSceneGraph/aipython/05-camera-manipulator.md +68 -0
  9. OpenSceneGraph/aipython/06-camera-effects.md +132 -0
  10. OpenSceneGraph/aipython/07-camera-manual.md +88 -0
  11. OpenSceneGraph/aipython/08-lighting.md +121 -0
  12. OpenSceneGraph/aipython/09-picking.md +90 -0
  13. OpenSceneGraph/aipython/10-rtt.md +199 -0
  14. OpenSceneGraph/aipython/11-mrt.md +104 -0
  15. OpenSceneGraph/aipython/12-gbuffer.md +48 -0
  16. OpenSceneGraph/aipython/15-shader-hotswap.md +72 -0
  17. OpenSceneGraph/aipython/17-particles.md +189 -0
  18. OpenSceneGraph/aipython/18-deterministic-captures.md +157 -0
  19. OpenSceneGraph/aipython/20-object-lifetime.md +77 -0
  20. OpenSceneGraph/aipython/25-async-osgpy.md +228 -0
  21. OpenSceneGraph/aipython/29-material.md +109 -0
  22. OpenSceneGraph/aipython/30-pbribl.md +162 -0
  23. OpenSceneGraph/aipython/40-typed-lights-gizmos.md +218 -0
  24. OpenSceneGraph/examples/__init__.py +24 -0
  25. OpenSceneGraph/examples/__main__.py +143 -0
  26. OpenSceneGraph/examples/blur.py +370 -0
  27. OpenSceneGraph/examples/info.py +126 -0
  28. OpenSceneGraph/examples/mrt.py +493 -0
  29. OpenSceneGraph/examples/pyosg_async.py +467 -0
  30. OpenSceneGraph/examples/pyosg_example.py +120 -0
  31. OpenSceneGraph/examples/pyosg_repl.py +842 -0
  32. OpenSceneGraph/examples/pyosg_visitor.py +138 -0
  33. OpenSceneGraph/ktx.dll +0 -0
  34. OpenSceneGraph/ktx.lib +0 -0
  35. OpenSceneGraph/osg.lib +0 -0
  36. OpenSceneGraph/osg161-osg.dll +0 -0
  37. OpenSceneGraph/osg161-osgAnimation.dll +0 -0
  38. OpenSceneGraph/osg161-osgDB.dll +0 -0
  39. OpenSceneGraph/osg161-osgFX.dll +0 -0
  40. OpenSceneGraph/osg161-osgGA.dll +0 -0
  41. OpenSceneGraph/osg161-osgText.dll +0 -0
  42. OpenSceneGraph/osg161-osgUtil.dll +0 -0
  43. OpenSceneGraph/osg161-osgViewer.dll +0 -0
  44. OpenSceneGraph/osg161-osgWidget.dll +0 -0
  45. OpenSceneGraph/osgAnimation.lib +0 -0
  46. OpenSceneGraph/osgDB.lib +0 -0
  47. OpenSceneGraph/osgFX.lib +0 -0
  48. OpenSceneGraph/osgGA.lib +0 -0
  49. OpenSceneGraph/osgPlugins-3.6.5/jpeg62-ebb2f26be87097e77bafd1e9095b4820.dll +0 -0
  50. OpenSceneGraph/osgPlugins-3.6.5/ktx.dll +0 -0
  51. OpenSceneGraph/osgPlugins-3.6.5/liblzma-b9cff3753c4848b9ff1350ba5a053c6e.dll +0 -0
  52. OpenSceneGraph/osgPlugins-3.6.5/libpng16-36b7e1e8d185cb21280e5688f99660c0.dll +0 -0
  53. OpenSceneGraph/osgPlugins-3.6.5/msvcp140.dll +0 -0
  54. OpenSceneGraph/osgPlugins-3.6.5/osgdb_bmp.dll +0 -0
  55. OpenSceneGraph/osgPlugins-3.6.5/osgdb_dds.dll +0 -0
  56. OpenSceneGraph/osgPlugins-3.6.5/osgdb_gltf.dll +0 -0
  57. OpenSceneGraph/osgPlugins-3.6.5/osgdb_hdr.dll +0 -0
  58. OpenSceneGraph/osgPlugins-3.6.5/osgdb_jpeg.dll +0 -0
  59. OpenSceneGraph/osgPlugins-3.6.5/osgdb_ktx2.dll +0 -0
  60. OpenSceneGraph/osgPlugins-3.6.5/osgdb_obj.dll +0 -0
  61. OpenSceneGraph/osgPlugins-3.6.5/osgdb_osg.dll +0 -0
  62. OpenSceneGraph/osgPlugins-3.6.5/osgdb_png.dll +0 -0
  63. OpenSceneGraph/osgPlugins-3.6.5/osgdb_pnm.dll +0 -0
  64. OpenSceneGraph/osgPlugins-3.6.5/osgdb_rgb.dll +0 -0
  65. OpenSceneGraph/osgPlugins-3.6.5/osgdb_serializers_osg.dll +0 -0
  66. OpenSceneGraph/osgPlugins-3.6.5/osgdb_stl.dll +0 -0
  67. OpenSceneGraph/osgPlugins-3.6.5/osgdb_tga.dll +0 -0
  68. OpenSceneGraph/osgPlugins-3.6.5/osgdb_tiff.dll +0 -0
  69. OpenSceneGraph/osgPlugins-3.6.5/tiff-1defa8059e5ba115a3ab4b120de6f4bb.dll +0 -0
  70. OpenSceneGraph/osgPlugins-3.6.5/z.dll +0 -0
  71. OpenSceneGraph/osgText.lib +0 -0
  72. OpenSceneGraph/osgUtil.lib +0 -0
  73. OpenSceneGraph/osgViewer.lib +0 -0
  74. OpenSceneGraph/osgWidget.lib +0 -0
  75. OpenSceneGraph/osgx.cp313-win_amd64.pyd +0 -0
  76. OpenSceneGraph/osgx_static.lib +0 -0
  77. OpenSceneGraph/ot21-OpenThreads.dll +0 -0
  78. openscenegraph-0.1.2.dist-info/DELVEWHEEL +2 -0
  79. openscenegraph-0.1.2.dist-info/METADATA +563 -0
  80. openscenegraph-0.1.2.dist-info/RECORD +90 -0
  81. openscenegraph-0.1.2.dist-info/WHEEL +5 -0
  82. openscenegraph-0.1.2.dist-info/entry_points.txt +6 -0
  83. openscenegraph-0.1.2.dist-info/licenses/LICENSE +21 -0
  84. openscenegraph.libs/jpeg62-ebb2f26be87097e77bafd1e9095b4820.dll +0 -0
  85. openscenegraph.libs/liblzma-b9cff3753c4848b9ff1350ba5a053c6e.dll +0 -0
  86. openscenegraph.libs/libpng16-36b7e1e8d185cb21280e5688f99660c0.dll +0 -0
  87. openscenegraph.libs/msvcp140.dll +0 -0
  88. openscenegraph.libs/tiff-1defa8059e5ba115a3ab4b120de6f4bb.dll +0 -0
  89. openscenegraph.libs/z.dll +0 -0
  90. osgx.py +7 -0
@@ -0,0 +1,228 @@
1
+ # Async OSG.py: the pattern, the ceiling, and the glTF case study
2
+
3
+ Two reusable pieces (`examples/pyosg_async.py`): running `viewer.frame()` as
4
+ an ordinary `asyncio` task instead of a hand-rolled pump loop, and draining a
5
+ `.poll()`-shaped progress object from the coroutine already awaiting the
6
+ background work, instead of routing progress through a queue and a
7
+ `call_soon_threadsafe` bridge. `osgx.gltf.readNodeFile()` +
8
+ `osgx.gltf.AsyncProgress` is the concrete instance this grew out of and stays
9
+ the worked example throughout.
10
+
11
+ ## The Node.js comparison
12
+
13
+ The intuitive model is Node's "fire and forget": kick off slow work on a
14
+ thread pool, keep the event loop responsive, `await` a result. Node's worker
15
+ threads never touch the JS engine while they work; they post exactly one
16
+ completion event back to the single JS thread.
17
+
18
+ OSG.py can genuinely match this — where it's real, it's *stronger*, since a
19
+ background thread doing off-GIL native work executes truly in parallel with
20
+ rendering on a second core, not just an I/O-completion callback on one
21
+ thread. But CPython's GIL is a sharper, stickier constraint than "the JS
22
+ engine" is in Node's model: it applies to *any* thread running *any* Python
23
+ bytecode, including a Python-written progress callback called from C++, not
24
+ just to a language-level boundary a worker can choose to respect.
25
+
26
+ ## The mechanism: why a naive async loader can be slower than sync
27
+
28
+ `Viewer.frame()`'s pybind11 binding (`pyosg/pyosgViewer.cpp`) deliberately
29
+ does **not** release the GIL when `getThreadingModel() == SingleThreaded`
30
+ (this project's standing default) — see [`01-core.md`](01-core.md) rule 8.
31
+ A Python pump loop (`while not viewer.done: viewer.frame(); ...`) holds the
32
+ GIL, uninterrupted, for `frame()`'s entire C++ execution. Any background
33
+ thread that calls back into Python to report progress — including
34
+ `pybind11x::put_nowait()` (`etc/pybind11x.hpp`), which does
35
+ `py::gil_scoped_acquire` — cannot acquire the GIL until `frame()` returns. A
36
+ loader reporting progress dozens of times per load this way spends most of
37
+ its time waiting on the GIL, not working: measured as ~2x slower wall-clock
38
+ than sync loading the same model, despite the loader itself doing less work.
39
+
40
+ **Ruled out:** vsync/compositor pacing. Disabling vsync
41
+ (`__GL_SYNC_TO_VBLANK=0`) made no measurable difference — vsync caps the pump
42
+ loop's call rate, but each `frame()` call still costs real GIL-held C++ time
43
+ regardless, and the aggregate GIL saturation stays similar whether
44
+ distributed as few long holds (vsync-on) or many short ones (vsync-off).
45
+
46
+ ## Two valid ways to get data out of a background thread
47
+
48
+ Both exist in this codebase for different situations. Neither is obsolete in
49
+ favor of the other.
50
+
51
+ **Push** (`pybind11x::StopEvent` + `pybind11x::put_nowait()`,
52
+ `examples/pyosg-async.py`'s `task_cpp_example`): the background thread calls
53
+ `put_nowait(loop, queue, ...)`, which acquires the GIL and schedules
54
+ `queue.put_nowait(...)` via `call_soon_threadsafe`. Correct when the thread
55
+ needs to hand back an irregular, genuinely Python-shaped value that doesn't
56
+ reduce to a few numbers, or that only happens occasionally — the GIL
57
+ acquisition cost is fine when infrequent.
58
+
59
+ **Poll** (`pybind11x::PollableProgress<Stage>`, `osgx.gltf.AsyncProgress`,
60
+ `examples/pyosg-async-gltf.py`): the background thread writes into
61
+ independent `std::atomic` fields — no GIL, ever, from that side. A poller
62
+ (which already owns the GIL) calls `.poll()` on its own schedule — a few
63
+ relaxed atomic loads, effectively free. Use this for a hot native loop's
64
+ simple, frequent, numeric tick (a progress bar) — pushing at that frequency
65
+ is exactly what produces the mechanism above.
66
+
67
+ Rule of thumb: if the background thread needs to call back into arbitrary
68
+ Python, push, rarely. If it's just numbers a poller can pull, poll — but on
69
+ a real cadence, not a busy-loop (next section).
70
+
71
+ ## Gotcha: a poll loop still needs a real, positive sleep
72
+
73
+ `poll()` has no GIL cost, but `asyncio.sleep(0)` between checks isn't a real
74
+ sleep — it's a bare cooperative yield, so a loop built purely to poll with it
75
+ becomes an unthrottled busy-loop: near 100% of one CPU core spinning on
76
+ polling overhead, real OS-level contention with the background thread's
77
+ actual work. Measured **~75% slower** than the push-based version this
78
+ pattern was meant to improve on, before being fixed with a real default
79
+ poll interval. "Poll as often as convenient" only holds when the loop
80
+ already exists for another reason (a render loop polling implicitly via its
81
+ own 60fps cadence is free) — a loop whose only job is polling needs its own
82
+ real `asyncio.sleep(dt)`. `run_with_progress()`'s default `poll_interval` is
83
+ `1.0 / 60.0` — no reason to poll progress faster than it could be displayed.
84
+
85
+ ## Make the render loop an ordinary `asyncio` task
86
+
87
+ Don't hand-roll the event loop pump:
88
+
89
+ ```python
90
+ while not viewer.done:
91
+ viewer.frame()
92
+ loop.run_until_complete(asyncio.sleep(0))
93
+ # ...manually drain a queue...
94
+ ```
95
+
96
+ An ordinary `async def` wrapping `viewer.frame()` already works — between
97
+ calls, control genuinely returns to the Python bytecode eval loop, same as
98
+ any other coroutine's await point. `examples/pyosg_async.py`'s `run()`:
99
+
100
+ ```python
101
+ async def run(viewer, *coros, fps=60, max_frames=None):
102
+ async def render():
103
+ while not viewer.done:
104
+ viewer.frame()
105
+ await asyncio.sleep(1.0 / fps)
106
+
107
+ # NOT asyncio.gather(render(), *coros) — that waits for every task to finish,
108
+ # so a coros task outliving the window closing would hang the session open.
109
+ # The window closing (render_task finishing) must always end the session
110
+ # immediately, cancelling coros; a coros task finishing must never end the
111
+ # session by itself.
112
+ render_task = asyncio.ensure_future(render())
113
+ other_tasks = [asyncio.ensure_future(c) for c in coros]
114
+ pending = {render_task, *other_tasks}
115
+ try:
116
+ while True:
117
+ done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
118
+ for t in done:
119
+ if not t.cancelled() and t.exception() is not None:
120
+ raise t.exception()
121
+ if render_task not in pending:
122
+ break # the window closed — the only thing that ends the session
123
+ finally:
124
+ for t in (render_task, *other_tasks):
125
+ if not t.done():
126
+ t.cancel()
127
+ ...
128
+ ```
129
+
130
+ Loading code then reads close to plain `await`:
131
+
132
+ ```python
133
+ async def load(viewer, path, stop, progress):
134
+ node = await asyncio.to_thread(osgx.gltf.readNodeFile, path, stop, progress)
135
+ viewer.sceneData.children.append(node)
136
+
137
+ asyncio.run(pyosg_async.run(viewer, load(viewer, path, stop, progress), bar.watch(progress)))
138
+ ```
139
+
140
+ Two shapes for reacting to progress, both built on `run()`:
141
+
142
+ - `pyosg_async.run_with_progress(blocking_fn, *args, progress=, on_progress=, stop=, poll_interval=1.0/60.0)`
143
+ — inline-callback shape: starts `blocking_fn` via `asyncio.to_thread`,
144
+ polls progress itself, forwards to `on_progress`, returns the result.
145
+ Right when the wait and the reaction naturally belong together (console
146
+ output).
147
+ - `Progress.watch(progress, poll_interval=1.0/60.0)` — decoupled-task shape:
148
+ its own coroutine, added directly to `run()`'s task list alongside the
149
+ load, neither aware of the other. Right for a separate visual progress bar
150
+ (`Progress`/`ProgressBar` — a real `osg.Camera` subclass, POST_RENDER +
151
+ `clearMask=0` + identity view/projection, same shape as `pyosg-fire.py`'s
152
+ `build_flash_camera()`).
153
+
154
+ **Only one poller per `AsyncProgress`.** Its "last seen generation" cursor
155
+ lives inside the C++ object itself (`AsyncProgress::seen`,
156
+ `~/dev/osgx/ext/python/osgx-gltf.cpp`), not per caller — two independent
157
+ consumers calling `.poll()` on the same instance steal updates from each
158
+ other; whichever polls first consumes that tick for both. Pick exactly one
159
+ poller per progress object.
160
+
161
+ ## The glTF case study: `readNodeFile()` + `AsyncProgress`
162
+
163
+ `osgx.gltf.readNodeFile(location, stop_event, progress)` is a plain blocking
164
+ call, meant to run via `asyncio.to_thread(...)`, returning its result
165
+ normally — there is no `readNodeFileAsync()`; that queue/loop-based function
166
+ no longer exists. It never touches Python once it starts, not even to report
167
+ progress — progress is written into an `osgx.gltf.AsyncProgress` purely
168
+ through atomics, read back via `.poll()`.
169
+
170
+ **Expected overhead: ~1.25x, not parity.** Isolating the loader entirely (no
171
+ Viewer, no `frame()` calls at all) shows its wall-clock time is within noise
172
+ of a sync `osgDB.readNodeFile()` call on the same model — the pure
173
+ background load has no more headroom to give. The remaining ~1.25x gap in a
174
+ real windowed async run is the genuine cost of rendering hundreds of
175
+ concurrent `frame()` calls *while* the load runs — the actual point of async
176
+ loading (a responsive, rendering viewer while data loads), not overhead left
177
+ to eliminate. Don't chase this ratio to 1.0x. Validate any change to this
178
+ path on a real model (dozens+ of nodes, load time comfortably over a
179
+ second) — a tiny model's load is too fast/noisy to show a signal either way.
180
+
181
+ **Unrelated:** a model with very large source textures (tens of MB PNGs) can
182
+ show multi-second stalls inside `building_nodes` that are pure
183
+ single-threaded CPU cost in `osgDB::readImageFile()`'s decode — identical in
184
+ both sync and async loaders, nothing to do with GIL/threading. If
185
+ `building_nodes` looks slow, check texture file sizes before assuming
186
+ scene-graph construction is the cost.
187
+
188
+ ## What this actually buys you
189
+
190
+ - A window that stays responsive immediately while a multi-second load
191
+ happens fully concurrently — real hardware parallelism, not a
192
+ cooperative-scheduling illusion.
193
+ - Ordinary `asyncio` composition: `await`, `gather`, cancellation
194
+ propagation, once the render loop is just another task.
195
+ - One idiom (`run_with_progress`, `Progress.watch()`) reusable across every
196
+ future "blocking native call + simple progress ticks" operation, instead
197
+ of re-deriving queue-draining boilerplate per feature.
198
+
199
+ ## Where the ceiling is
200
+
201
+ - **The GIL is coarser than Node's model** — enforced on any thread running
202
+ any Python bytecode, including a Python-written progress callback passed
203
+ into C++. The poll design only avoids contention because the hot
204
+ reporting loop is pure C++ all the way down.
205
+ - **`frame()` not releasing the GIL is a hard wall, not a tuning knob** — see
206
+ [`01-core.md`](01-core.md) rule 8. Async design works *around* this
207
+ window, not by changing it.
208
+ - **Real CPU contention doesn't go away.** Two genuinely busy threads on
209
+ finite cores still cost real time together — the residual ~1.25x above is
210
+ concurrent rendering's actual cost, not a GIL artifact.
211
+ - **Cancellation stays cooperative.** A stop flag can only be checked
212
+ between units of native work — it cannot interrupt one opaque blocking
213
+ call already in flight (one `tinygltf` parse, one texture decode). Same
214
+ limitation as Node's `AbortController` against synchronous native work.
215
+
216
+ ## Where the pieces live
217
+
218
+ - `etc/pybind11x.hpp` — `StopEvent`, `put_nowait()` (push),
219
+ `PollableProgress<Stage>` (poll). Shared infra, used by both the core
220
+ pyosg bindings and osgx.
221
+ - `~/dev/osgx/ext/python/osgx-gltf.cpp` — `AsyncProgress` (the glTF-specific
222
+ `PollableProgress` binding) and `readNodeFile()`.
223
+ - `examples/pyosg_async.py` — `run()`, `run_with_progress()`, and
224
+ `Progress`/`ProgressBar`. Not yet part of the `OpenSceneGraph` package —
225
+ see `[[project_native_package_split]]`.
226
+ - `examples/pyosg-async.py` — push-based demo.
227
+ - `examples/pyosg-async-gltf.py` — poll-based demo, `load()` and
228
+ `bar.watch(progress)` as independent entries in `run()`'s task list.
@@ -0,0 +1,109 @@
1
+ # osgx.Material — one PBR material as a real StateAttribute
2
+
3
+ `osgx.Material` is a real `osg.StateAttribute` subclass: construct one,
4
+ set its properties, attach it to a `StateSet` like any other attribute. This
5
+ replaces hand-building a material UBO (`osg.FloatArray` +
6
+ `osg.UniformBufferBinding`) yourself.
7
+
8
+ ```python
9
+ import osgx
10
+
11
+ material = osgx.Material()
12
+
13
+ material.baseColor = osg.Vec4(0.95, 0.55, 0.12, 1.0)
14
+ material.roughness = 0.12
15
+ material.metallic = 1.0
16
+
17
+ drawable.stateSet.attributes.append(material)
18
+ ```
19
+
20
+ `StateSet.attributes[key] = value` (keyed by `osg.StateAttribute.Type`) also
21
+ works, but `.attributes.append(material)` is simplest — it reads the key off
22
+ the attribute's own `.type`.
23
+
24
+ ## Properties
25
+
26
+ | Property | Meaning |
27
+ |---|---|
28
+ | `baseColor` | `osg.Vec4` RGBA factor, multiplied against `baseColorMap` when one is set. |
29
+ | `roughness` | float factor. `0.0`–`0.1` reads as a sharp mirror-like reflection; `1.0` is fully matte. |
30
+ | `metallic` | float factor. `1.0` tints specular by `baseColor` (real metal); `0.0` keeps a neutral white `F0=0.04` dielectric specular. |
31
+ | `hasOcclusion` | bool. No dedicated texture slot — occlusion is read from `metallicRoughnessMap`'s R channel, so this flag opts in explicitly. |
32
+ | `baseColorMap`, `normalMap`, `metallicRoughnessMap`, `emissiveMap` | `osg.Texture2D` or `None`. Bind at the loader's conventional units (`osgx.gltf.shader.{BASE_COLOR,NORMAL,ORM,EMISSIVE}_TEXTURE_UNIT`). |
33
+
34
+ `hasBaseColorMap`/`hasMetallicRoughnessMap`/`hasNormalMap` are **not**
35
+ separate properties — derived automatically from whether the corresponding
36
+ `*Map` property is set. Setting `baseColorMap` and having the texture
37
+ actually sample are the same operation.
38
+
39
+ ## Shader side: the same `osgx_gltf_Material` SSBO as before
40
+
41
+ `Material` populates the exact same `osgx_gltf_Material` std430 buffer
42
+ (`#pragma osgx::gltf MATERIAL_INPUTS`) the real glTF loader populates for a
43
+ loaded asset, at the same binding (`osgx.MATERIAL_BINDING` /
44
+ `osgx.gltf.shader.MATERIAL_BINDING`, both aliases of the same constant). A
45
+ minimal factor-only fragment shader:
46
+
47
+ ```glsl
48
+ #pragma osgx::gltf MATERIAL_INPUTS
49
+ #pragma osgx::pbr MATERIAL_STRUCT, DIRECT_LIGHTING_DECL
50
+
51
+ osgx_Material mat;
52
+
53
+ mat.albedo = osgx_gltf_material.baseColorFactor.rgb;
54
+ mat.ao = 1.0;
55
+ mat.roughness = clamp(osgx_gltf_material.roughnessFactor, 0.04, 1.0);
56
+ mat.metallic = osgx_gltf_material.metallicFactor;
57
+ mat.F0 = mix(vec3(0.04), mat.albedo, mat.metallic);
58
+ ```
59
+
60
+ Clamp roughness away from exactly `0.0` (`0.04` here) — a true zero
61
+ denominator in the GGX distribution term is a real division-by-near-zero.
62
+
63
+ ## Works on a plain `osg.ShapeDrawable`, not just osgx shapes
64
+
65
+ `Material` is a StateSet attribute like `osg.Program` or `osg.BlendFunc` — it
66
+ doesn't care what built the geometry. Confirmed on both `osgx.Icosahedron`
67
+ and a bare `osg.ShapeDrawable(osg.Sphere(...))`. One gotcha specific to
68
+ `ShapeDrawable`, unrelated to `Material` itself: it builds its geometry with
69
+ only classic `setVertexArray()`/`setNormalArray()` calls, not the extra
70
+ `setVertexAttribArray(0/1, ...)` calls `osgx.Polyhedron`-derived shapes
71
+ (`Cube`, `Icosahedron`) also make so a custom core-profile `Program` can rely
72
+ on fixed `position`/`normal` attribute locations. A vertex shader for a
73
+ `ShapeDrawable` should use OSG's own auto-aliased `osg_Vertex`/`osg_Normal`
74
+ attribute names instead — the same convention `pyosg-dynamic.py`/
75
+ `pyosg-hover.py`/`pyosg-picking.py` already use.
76
+
77
+ ## One material per mesh, not per face or per object instance
78
+
79
+ Match how glTF (and every real exporter format) works: one `Material` per
80
+ mesh/primitive. `StateAttribute`s only apply at Drawable granularity. For
81
+ visibly different materials across the faces of one shape, either give each
82
+ face its own small Drawable/Geode, or keep one Drawable and drive the
83
+ variation through a per-vertex attribute feeding a shader-side branch/array
84
+ yourself — `pyosg-material.py`'s "glitter" scene's `GLITTER_MATERIAL_COMBOS` is the working
85
+ example of the second approach, and deliberately hand-rolls its own
86
+ `osgx_Material` struct from vertex data rather than using
87
+ `osgx.Material`.
88
+
89
+ ## `StateAttribute::Type` and dedup
90
+
91
+ `Material.type` is `osg.StateAttribute.CAPABILITY` — its own claimed slot.
92
+ Two `Material`s with equal factors and identical texture objects compare
93
+ equal (`compare()` is real), so OSG's state-sorting can skip a redundant
94
+ re-apply between drawables that share one material — e.g. several primitives
95
+ loaded from the same glTF material, since the loader's `TextureLoader`
96
+ already caches/shares `Texture2D` objects by source index.
97
+
98
+ ## Verify a live result
99
+
100
+ If reflections look completely absent at low roughness/high metallic,
101
+ suspect the geometry before the material: flat facets under a single
102
+ directional light (no IBL environment) rarely catch a legible specular
103
+ highlight or environment reflection, regardless of roughness. Add a smooth
104
+ `osg.ShapeDrawable(osg.Sphere(...))` "chrome ball" control (max metallic,
105
+ near-zero roughness, near-white base color) — if it shows a clear reflection
106
+ and the original shape doesn't, that's a geometry limitation, not a material
107
+ or shader bug. `examples/pyosg-material-lab.py` is exactly this setup, with
108
+ `--hdr`/`--env` wiring a real environment through
109
+ `osgx.gltf.pbribl.PBRIBLEnvironment` (see [`30-pbribl.md`](30-pbribl.md)).
@@ -0,0 +1,162 @@
1
+ # Reflective PBR/IBL for ordinary OSG geometry
2
+
3
+ `osgx.gltf.pbribl.PBRIBLScene.create()` is not limited to glTF-loaded nodes —
4
+ it works with an ordinary `osg.ShapeDrawable`, provided the drawable carries
5
+ an `osgx.Material` (see [`29-material.md`](29-material.md)) — a real
6
+ `StateAttribute` — so there's a real `osgx_gltf_Material` buffer for the
7
+ renderer to read. This file covers the environment-loading and IBL-scene
8
+ side. `PBRIBLEnvironment`/`PBRIBLScene` are classes with static factory
9
+ methods (`.load()`/`.prepare()`/`.create()`), not free functions.
10
+
11
+ ## Minimal live REPL setup
12
+
13
+ ```python
14
+ import osgx
15
+
16
+ sphere = osg.Sphere(osg.Vec3(0.0, 0.0, 0.0), 2.0)
17
+ drawable = osg.ShapeDrawable(sphere, osg.TessellationHints(detailRatio=2.0))
18
+ geode = osg.Geode(drawables=(drawable,))
19
+
20
+ material = osgx.Material()
21
+ material.baseColor = osg.Vec4(0.95, 0.55, 0.12, 1.0)
22
+ material.roughness = 0.12
23
+ material.metallic = 1.0
24
+ drawable.stateSet.attributes.append(material)
25
+
26
+ environment = osgx.gltf.pbribl.PBRIBLEnvironment.load(
27
+ "/home/cubicool/dev/osgx/BUILD-g++-13.3.0-NOASAN/env/papermill.gltf",
28
+ )
29
+ pbr = osgx.gltf.pbribl.PBRIBLScene.create(
30
+ geode, environment, iblDiffuseIntensity=1.0, iblSpecularIntensity=1.0
31
+ )
32
+
33
+ if not environment.valid() or not pbr.valid():
34
+ raise RuntimeError("PBR/IBL setup failed")
35
+
36
+ root = osg.Group()
37
+
38
+ # The environment root is present when the manifest uses a built-in BRDF LUT.
39
+ if environment.root is not None:
40
+ root.children.append(environment.root)
41
+
42
+ root.children.append(pbr.node)
43
+ viewer.sceneData = root
44
+ viewer.camera.clearColor = osg.Vec4(48.0 / 255.0, 53.0 / 255.0, 66.0 / 255.0, 1.0)
45
+ viewer.cameraManipulator = osgGA.TrackballManipulator()
46
+ ```
47
+
48
+ ## Use a pre-baked `osgx_pbribl` environment manifest
49
+
50
+ The `.gltf` files in `osgx`'s build `env/` directory are not ordinary scene
51
+ models: they carry the custom `osgx_pbribl` extension pointing at matching
52
+ pre-baked specular and diffuse KTX2 cubemaps beside the manifest. Load one
53
+ directly instead of preparing from HDR:
54
+
55
+ ```python
56
+ environment = osgx.gltf.pbribl.PBRIBLEnvironment.load(
57
+ "/home/cubicool/dev/osgx/BUILD-g++-13.3.0-NOASAN/env/papermill.gltf",
58
+ )
59
+
60
+ if not environment.valid():
61
+ raise RuntimeError("failed to load PBR/IBL environment")
62
+ ```
63
+
64
+ Use `PBRIBLEnvironment.prepare("environment.hdr", lutSize=1024)` only when
65
+ the entire environment should be baked dynamically from one HDR source — it
66
+ bakes diffuse irradiance, GGX-prefiltered specular, and the BRDF LUT live
67
+ from that one source; add `environment.root` to the rendered graph so its
68
+ `PRE_RENDER` passes can populate the generated textures. The helper is
69
+ IBL-only and does not invent authored/direct lights; generic light rigs are
70
+ `osgx` (see [`40-typed-lights-gizmos.md`](40-typed-lights-gizmos.md)),
71
+ and glTF-authored camera/`KHR_lights_punctual` support is separate loader
72
+ work.
73
+
74
+ ### Switch a running scene
75
+
76
+ Given the `geode`/`viewer` variables from the setup above:
77
+
78
+ ```python
79
+ environment = osgx.gltf.pbribl.PBRIBLEnvironment.load(
80
+ "/home/cubicool/dev/osgx/BUILD-g++-13.3.0-NOASAN/env/Cannon_Exterior.gltf",
81
+ )
82
+
83
+ if not environment.valid():
84
+ raise RuntimeError("failed to load PBR/IBL environment")
85
+
86
+ pbr = osgx.gltf.pbribl.PBRIBLScene.create(
87
+ geode, environment, iblDiffuseIntensity=1.0, iblSpecularIntensity=1.0
88
+ )
89
+
90
+ if not pbr.valid():
91
+ raise RuntimeError("failed to apply PBR/IBL environment")
92
+
93
+ root = osg.Group()
94
+
95
+ if environment.root is not None:
96
+ root.children.append(environment.root)
97
+
98
+ root.children.append(pbr.node)
99
+ viewer.sceneData = root
100
+ ```
101
+
102
+ Do not merely replace `environment.root`: `PBRIBLScene.create()` owns the
103
+ program state and its environment texture bindings, so call it again for the
104
+ new environment. The material attached to `drawable` does not need to be
105
+ rebuilt.
106
+
107
+ ## Extra `PBRIBLScene.create()` options
108
+
109
+ `create(node, environment, iblDiffuseIntensity=1.0, iblSpecularIntensity=1.0,
110
+ diagnostics=False, shadowMap=None, hooks=[])`:
111
+
112
+ - `diagnostics=True` builds `pbr.debugMode` for switching between
113
+ combined/diffuse/specular/normal/roughness/diffuse-IBL-only visualizations
114
+ (`examples/pyosg-khronos-viewer.py`'s `Diagnostics` event handler cycles
115
+ it with number keys).
116
+ - `shadowMap` accepts an `osgx.ShadowMap` (see
117
+ [`10-rtt.md`](10-rtt.md)) to shadow the light at `LightSet` index
118
+ `shadowMap.casterIndex`; omit it for unshadowed direct light.
119
+ - `hooks` is a plain list of `(osgx.Hook, osg.Shader)` pairs
120
+ substituting one of this Program's built-in shader slots (`osgx.Hook.Skinning`,
121
+ `osgx.Hook.Tonemap`). Each hook *replaces* the built-in definition (GLSL
122
+ allows one body per function; adding a second is a link error, not an
123
+ override) — `osgx.gltf.shader.SKINNING_HOOK_LINEAR_BLEND` (wrapped in
124
+ `osgx.gltf.pbribl.resolveShaderLibs()`) enables real joint-matrix skinning
125
+ in place of the identity-passthrough default.
126
+
127
+ There is also a deferred, G-buffer-split path for scenes with many lights —
128
+ `PBRIBLGBuffer.create(node, width, height)` (material-only geometry pass) →
129
+ `PBRIBLLightingScene.create(gbuffer, environment, mainCamera, ...)`
130
+ (lighting pass reading the G-buffer) — not covered in the minimal setup
131
+ above; reach for it only once a scene's light count/overdraw makes the
132
+ single-pass `PBRIBLScene` genuinely too expensive.
133
+
134
+ ## Hand-assembled shader (no `osgx.Material`)
135
+
136
+ `osgx.Material` is the supported way to populate the material data (see
137
+ [`29-material.md`](29-material.md)) — it's a real `StateAttribute`, not
138
+ something worth hand-rolling. If assembling a shader by hand regardless, the
139
+ buffer is `#pragma osgx::gltf MATERIAL_INPUTS`'s `osgx_gltf_Material`: a
140
+ `std430` SSBO (`baseColorFactor` vec4, `roughnessFactor`, `metallicFactor`,
141
+ then four map-presence float flags), plus a separate `GLTFTextures`
142
+ sampler-struct uniform and `osgx_gltf_alphaMode`/`osgx_gltf_alphaCutoff`
143
+ uniforms for texture-backed materials. Sampler units and UV arrays must
144
+ follow `osgx.gltf.shader`'s glTF interface — at that point
145
+ `osgx.Material` is almost always the clearer path.
146
+
147
+ The Program and IBL textures attach to `geode`; the material attaches to
148
+ `drawable`. That split lets one PBR scene contain several drawables with
149
+ independent materials.
150
+
151
+ ## Verify a live result
152
+
153
+ Do not use bare `Image.readPixels()` from the prompt. Queue the capture on
154
+ the REPL controller instead:
155
+
156
+ ```python
157
+ await _osg_repl_controller.capture_framebuffer("/tmp/pbr-sphere.png")
158
+ ```
159
+
160
+ The sphere should show the environment reflected across its surface.
161
+ Increase roughness toward `1.0` for a blurrier reflection; lower metallic
162
+ toward `0.0` for a dielectric surface with a diffuse IBL term.