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.
- OpenSceneGraph/OpenThreads.lib +0 -0
- OpenSceneGraph/_OpenSceneGraph.cp313-win_amd64.pyd +0 -0
- OpenSceneGraph/__init__.py +52 -0
- OpenSceneGraph/aipython/00-index.md +44 -0
- OpenSceneGraph/aipython/01-core.md +255 -0
- OpenSceneGraph/aipython/02-inspect.md +81 -0
- OpenSceneGraph/aipython/03-headless-frames.md +149 -0
- OpenSceneGraph/aipython/05-camera-manipulator.md +68 -0
- OpenSceneGraph/aipython/06-camera-effects.md +132 -0
- OpenSceneGraph/aipython/07-camera-manual.md +88 -0
- OpenSceneGraph/aipython/08-lighting.md +121 -0
- OpenSceneGraph/aipython/09-picking.md +90 -0
- OpenSceneGraph/aipython/10-rtt.md +199 -0
- OpenSceneGraph/aipython/11-mrt.md +104 -0
- OpenSceneGraph/aipython/12-gbuffer.md +48 -0
- OpenSceneGraph/aipython/15-shader-hotswap.md +72 -0
- OpenSceneGraph/aipython/17-particles.md +189 -0
- OpenSceneGraph/aipython/18-deterministic-captures.md +157 -0
- OpenSceneGraph/aipython/20-object-lifetime.md +77 -0
- OpenSceneGraph/aipython/25-async-osgpy.md +228 -0
- OpenSceneGraph/aipython/29-material.md +109 -0
- OpenSceneGraph/aipython/30-pbribl.md +162 -0
- OpenSceneGraph/aipython/40-typed-lights-gizmos.md +218 -0
- OpenSceneGraph/examples/__init__.py +24 -0
- OpenSceneGraph/examples/__main__.py +143 -0
- OpenSceneGraph/examples/blur.py +370 -0
- OpenSceneGraph/examples/info.py +126 -0
- OpenSceneGraph/examples/mrt.py +493 -0
- OpenSceneGraph/examples/pyosg_async.py +467 -0
- OpenSceneGraph/examples/pyosg_example.py +120 -0
- OpenSceneGraph/examples/pyosg_repl.py +842 -0
- OpenSceneGraph/examples/pyosg_visitor.py +138 -0
- OpenSceneGraph/ktx.dll +0 -0
- OpenSceneGraph/ktx.lib +0 -0
- OpenSceneGraph/osg.lib +0 -0
- OpenSceneGraph/osg161-osg.dll +0 -0
- OpenSceneGraph/osg161-osgAnimation.dll +0 -0
- OpenSceneGraph/osg161-osgDB.dll +0 -0
- OpenSceneGraph/osg161-osgFX.dll +0 -0
- OpenSceneGraph/osg161-osgGA.dll +0 -0
- OpenSceneGraph/osg161-osgText.dll +0 -0
- OpenSceneGraph/osg161-osgUtil.dll +0 -0
- OpenSceneGraph/osg161-osgViewer.dll +0 -0
- OpenSceneGraph/osg161-osgWidget.dll +0 -0
- OpenSceneGraph/osgAnimation.lib +0 -0
- OpenSceneGraph/osgDB.lib +0 -0
- OpenSceneGraph/osgFX.lib +0 -0
- OpenSceneGraph/osgGA.lib +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/jpeg62-ebb2f26be87097e77bafd1e9095b4820.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/ktx.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/liblzma-b9cff3753c4848b9ff1350ba5a053c6e.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/libpng16-36b7e1e8d185cb21280e5688f99660c0.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/msvcp140.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_bmp.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_dds.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_gltf.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_hdr.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_jpeg.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_ktx2.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_obj.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_osg.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_png.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_pnm.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_rgb.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_serializers_osg.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_stl.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_tga.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_tiff.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/tiff-1defa8059e5ba115a3ab4b120de6f4bb.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/z.dll +0 -0
- OpenSceneGraph/osgText.lib +0 -0
- OpenSceneGraph/osgUtil.lib +0 -0
- OpenSceneGraph/osgViewer.lib +0 -0
- OpenSceneGraph/osgWidget.lib +0 -0
- OpenSceneGraph/osgx.cp313-win_amd64.pyd +0 -0
- OpenSceneGraph/osgx_static.lib +0 -0
- OpenSceneGraph/ot21-OpenThreads.dll +0 -0
- openscenegraph-0.1.2.dist-info/DELVEWHEEL +2 -0
- openscenegraph-0.1.2.dist-info/METADATA +563 -0
- openscenegraph-0.1.2.dist-info/RECORD +90 -0
- openscenegraph-0.1.2.dist-info/WHEEL +5 -0
- openscenegraph-0.1.2.dist-info/entry_points.txt +6 -0
- openscenegraph-0.1.2.dist-info/licenses/LICENSE +21 -0
- openscenegraph.libs/jpeg62-ebb2f26be87097e77bafd1e9095b4820.dll +0 -0
- openscenegraph.libs/liblzma-b9cff3753c4848b9ff1350ba5a053c6e.dll +0 -0
- openscenegraph.libs/libpng16-36b7e1e8d185cb21280e5688f99660c0.dll +0 -0
- openscenegraph.libs/msvcp140.dll +0 -0
- openscenegraph.libs/tiff-1defa8059e5ba115a3ab4b120de6f4bb.dll +0 -0
- openscenegraph.libs/z.dll +0 -0
- 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.
|