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,467 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# Small, reusable pieces of the "async OSG.py" idiom -- not a framework, just the two things every
|
|
4
|
+
# example kept hand-rolling: (1) running viewer.frame() as an ordinary asyncio task instead of a
|
|
5
|
+
# bespoke synchronous pump loop, so it composes with `await` like any other coroutine, and (2)
|
|
6
|
+
# draining a poll()-shaped progress object (see osgx.gltf.AsyncProgress) from the coroutine that's
|
|
7
|
+
# already awaiting the background work, instead of routing progress through a queue and a
|
|
8
|
+
# call_soon_threadsafe bridge. See aipython/25-async-osgpy.md for why the poll-based half of this
|
|
9
|
+
# exists (a real measured 2x async/sync slowdown from the push-based alternative, caused by the
|
|
10
|
+
# background thread contending for the GIL with a render loop that -- under OSG_THREADING=
|
|
11
|
+
# SingleThreaded -- almost never voluntarily releases it).
|
|
12
|
+
#
|
|
13
|
+
# Part of the base openscenegraph wheel via examples/manifest.cmake's PYOSG_CORE_EXAMPLES, same as
|
|
14
|
+
# pyosg_repl.py/pyosg_visitor.py/pyosg_example.py -- stays here in examples/ rather than moving
|
|
15
|
+
# into python/OpenSceneGraph/, matching how those three already resolved the same question.
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
|
|
19
|
+
from OpenSceneGraph import *
|
|
20
|
+
from OpenSceneGraph.GL import *
|
|
21
|
+
|
|
22
|
+
import osgx
|
|
23
|
+
|
|
24
|
+
async def run(viewer, *coros, fps=60, max_frames=None):
|
|
25
|
+
"""Runs viewer.frame() as an ordinary asyncio task alongside `coros`, so application code
|
|
26
|
+
never hand-writes its own `while not viewer.done: viewer.frame(); loop.run_until_complete(...)`
|
|
27
|
+
pump -- rendering is just another coroutine competing for the same event loop, the same way a
|
|
28
|
+
browser's requestAnimationFrame callback or Node's setInterval share their loop with everything
|
|
29
|
+
else.
|
|
30
|
+
|
|
31
|
+
The window closing (`viewer.done` becoming true, i.e. the user hit Escape or closed it) always
|
|
32
|
+
ends the session immediately, cancelling any `coros` still running -- same as it would in a
|
|
33
|
+
hand-written `while not viewer.done: ...` loop, where nothing after the loop runs once it
|
|
34
|
+
exits. Conversely, a `coros` task finishing early does NOT end the session on its own -- a
|
|
35
|
+
one-shot startup task (e.g. a single load-and-attach coroutine) completing must not close the
|
|
36
|
+
window out from under whoever's still looking at it. These two exit conditions are genuinely
|
|
37
|
+
asymmetric, not "wait for everything": treat `render()`'s own completion as authoritative, and
|
|
38
|
+
`coros` completing as informational only, unless one of them raises (which ends the session
|
|
39
|
+
either way, exception propagated here).
|
|
40
|
+
|
|
41
|
+
`fps` bounds how often frame() is called -- await asyncio.sleep(1 / fps) between calls, a real
|
|
42
|
+
sleep, so this task genuinely yields control (including, if it's a busy moment, the GIL) rather
|
|
43
|
+
than spinning. There is no reason to poll faster than the display can show anyway.
|
|
44
|
+
|
|
45
|
+
`max_frames`, if given, sets `viewer.done = True` after exactly that many frame() calls --
|
|
46
|
+
matching how the window actually closes (Escape / OS close button), NOT `viewer.close()`
|
|
47
|
+
(see the comment at the actual call site for why that's deliberately avoided) -- for
|
|
48
|
+
deterministic, scriptable runs (apitrace captures, crash repros) that don't depend on a
|
|
49
|
+
human pressing Escape at some approximate moment.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
frame_interval = 1.0 / fps
|
|
53
|
+
|
|
54
|
+
async def render():
|
|
55
|
+
count = 0
|
|
56
|
+
|
|
57
|
+
while not viewer.done:
|
|
58
|
+
viewer.frame()
|
|
59
|
+
|
|
60
|
+
count += 1
|
|
61
|
+
|
|
62
|
+
if max_frames is not None and count >= max_frames:
|
|
63
|
+
# NOT viewer.close() -- that calls GraphicsContext::close(), which (when this
|
|
64
|
+
# context isn't shared) unconditionally runs osg::deleteAllGLObjects(contextID):
|
|
65
|
+
# a blanket "delete every GL object ever registered for this context" sweep,
|
|
66
|
+
# regardless of whether the owning C++ objects are still alive. A still-alive
|
|
67
|
+
# orphaned Camera (kept alive by another task, like Progress.watch()) has its
|
|
68
|
+
# real GL-side Program/buffers deleted out from under it while it still believes
|
|
69
|
+
# it owns them -- confirmed 2026-08-23 as the likely mechanism behind a real
|
|
70
|
+
# "corrupted double-linked list" abort. `done = True` matches how a window
|
|
71
|
+
# actually closes (Escape / OS close button) -- no explicit teardown call here.
|
|
72
|
+
viewer.done = True
|
|
73
|
+
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
await asyncio.sleep(frame_interval)
|
|
77
|
+
|
|
78
|
+
render_task = asyncio.ensure_future(render())
|
|
79
|
+
other_tasks = [asyncio.ensure_future(c) for c in coros]
|
|
80
|
+
pending = {render_task, *other_tasks}
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
# Wait incrementally rather than for one fixed condition -- a `coros` task finishing (in
|
|
84
|
+
# `done`) is only checked for an exception and then dropped from `pending`; the loop keeps
|
|
85
|
+
# going. Only `render_task` leaving `pending` (the window actually closed) ends it, and
|
|
86
|
+
# only that specific check breaks the loop -- an exception from ANY task still ends things
|
|
87
|
+
# immediately via the raise below, on either task's completion.
|
|
88
|
+
while True:
|
|
89
|
+
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
|
|
90
|
+
|
|
91
|
+
for task in done:
|
|
92
|
+
if task.cancelled():
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
exc = task.exception()
|
|
96
|
+
|
|
97
|
+
if exc is not None:
|
|
98
|
+
raise exc
|
|
99
|
+
|
|
100
|
+
if render_task not in pending:
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
finally:
|
|
104
|
+
for task in (render_task, *other_tasks):
|
|
105
|
+
if not task.done():
|
|
106
|
+
task.cancel()
|
|
107
|
+
|
|
108
|
+
for task in (render_task, *other_tasks):
|
|
109
|
+
try:
|
|
110
|
+
await task
|
|
111
|
+
|
|
112
|
+
except (asyncio.CancelledError, Exception):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
async def run_with_progress(
|
|
116
|
+
blocking_fn,
|
|
117
|
+
*args,
|
|
118
|
+
progress=None,
|
|
119
|
+
on_progress=None,
|
|
120
|
+
stop=None,
|
|
121
|
+
poll_interval=1.0 / 60.0
|
|
122
|
+
):
|
|
123
|
+
"""Runs `blocking_fn(*args)` via asyncio.to_thread while polling `progress` (any object with a
|
|
124
|
+
no-arg poll() method returning None-or-an-update, e.g. osgx.gltf.AsyncProgress) from the
|
|
125
|
+
awaiting coroutine's own loop and forwarding each update to `on_progress`. Returns
|
|
126
|
+
blocking_fn's return value.
|
|
127
|
+
|
|
128
|
+
This is the "pull" half of the pattern: `blocking_fn` must never call back into Python itself
|
|
129
|
+
(no queue, no loop, no call_soon_threadsafe) -- it only needs to write into `progress` via
|
|
130
|
+
plain atomics, and this coroutine (which already owns the GIL as a matter of course, same as
|
|
131
|
+
any Python code) does the work of noticing and reacting to changes. The one exception is the
|
|
132
|
+
final return value, which really does cross back into Python exactly once, at completion --
|
|
133
|
+
that's asyncio.to_thread's own Future machinery, not something this function adds, and it was
|
|
134
|
+
never the source of the GIL contention this pattern exists to avoid (see the module docstring).
|
|
135
|
+
|
|
136
|
+
`poll_interval` MUST be a real, positive sleep, not 0 -- `poll()` itself being cheap (a few
|
|
137
|
+
atomic loads, no GIL crossing) is only free to call often when it's piggybacking on a loop
|
|
138
|
+
that already ticks for other reasons, like `run()`'s render loop above. This loop exists
|
|
139
|
+
*purely* to poll, so `await asyncio.sleep(0)` here would be a genuine unthrottled busy-loop --
|
|
140
|
+
CPython's zero-delay sleep is a bare cooperative yield, not a real wait, so this coroutine
|
|
141
|
+
would be rescheduled continuously for the entire duration of `blocking_fn`, burning ~100% of
|
|
142
|
+
one core on pure polling overhead. That's real OS-level CPU contention with the background
|
|
143
|
+
thread actually doing the work -- measured as a genuine slowdown (worse than the GIL
|
|
144
|
+
contention this pattern was built to remove), not a theoretical concern. Default matches a
|
|
145
|
+
typical 60fps render cadence; there's no reason to poll faster than progress can be displayed
|
|
146
|
+
anyway.
|
|
147
|
+
|
|
148
|
+
`stop` (a StopEvent), if given, is set() when this coroutine itself is cancelled, so
|
|
149
|
+
`blocking_fn` gets a chance to notice at its own cooperative checkpoints and return early --
|
|
150
|
+
cancellation here can't preempt a single opaque blocking call already in flight, same
|
|
151
|
+
limitation as `asyncio.CancelledError` has against any synchronous code.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
task = asyncio.ensure_future(asyncio.to_thread(blocking_fn, *args))
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
while not task.done():
|
|
158
|
+
if progress is not None and on_progress is not None:
|
|
159
|
+
update = progress.poll()
|
|
160
|
+
|
|
161
|
+
if update is not None:
|
|
162
|
+
on_progress(update)
|
|
163
|
+
|
|
164
|
+
await asyncio.sleep(poll_interval)
|
|
165
|
+
|
|
166
|
+
return await task
|
|
167
|
+
|
|
168
|
+
except asyncio.CancelledError:
|
|
169
|
+
if stop is not None:
|
|
170
|
+
stop.stop()
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
await asyncio.shield(task)
|
|
174
|
+
|
|
175
|
+
except asyncio.CancelledError:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
raise
|
|
179
|
+
|
|
180
|
+
class Progress(osg.Camera):
|
|
181
|
+
"""Base class for a screen-space progress indicator, rendered by a dedicated POST_RENDER
|
|
182
|
+
overlay Camera -- guaranteed to draw after (on top of) the main scene regardless of its
|
|
183
|
+
content, same shape as pyosg-fire.py's build_flash_camera(): identity view/projection
|
|
184
|
+
(children emit clip-space coordinates directly, ignoring gl_ModelViewProjectionMatrix
|
|
185
|
+
entirely), ABSOLUTE_RF, and clearMask=0 since this is an overlay -- clearing here would wipe
|
|
186
|
+
out everything the main camera already rendered this frame.
|
|
187
|
+
|
|
188
|
+
This is deliberately a real Python subclass of osg.Camera, not a plain osg.Camera returned
|
|
189
|
+
from a factory function with attributes bolted on afterward -- pybind11 types aren't
|
|
190
|
+
dynamic_attr (see feedback_avoid_dynamic_attr_use_proxy), so a genuine subclass is the only
|
|
191
|
+
way to get both `isinstance(x, osg.Camera)` (for attaching into a scene graph the normal way)
|
|
192
|
+
and Python-level state (`fraction`, `update()`) on the same object.
|
|
193
|
+
|
|
194
|
+
Subclasses provide the actual geometry/shader (a bar today; a spinner or gauge later) and
|
|
195
|
+
call `self._attach(geometry, program)` once, after `super().__init__()`, to wire them in.
|
|
196
|
+
The `uProgress`/`uFgColor`/`uBgColor` uniform contract is common to any progress
|
|
197
|
+
visualization and is created here, not per-subclass.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(
|
|
201
|
+
self,
|
|
202
|
+
width,
|
|
203
|
+
height,
|
|
204
|
+
fg_color=(0.2, 0.4, 0.2, 1.0),
|
|
205
|
+
bg_color=(0.15, 0.15, 0.15, 1.0),
|
|
206
|
+
**kwargs
|
|
207
|
+
):
|
|
208
|
+
super().__init__(**kwargs)
|
|
209
|
+
|
|
210
|
+
self.renderOrder = osg.Camera.POST_RENDER
|
|
211
|
+
self.clearMask = 0
|
|
212
|
+
self.viewport = osg.Viewport(0, 0, width, height)
|
|
213
|
+
self.projectionMatrix = osg.Matrix.identity()
|
|
214
|
+
self.viewMatrix = osg.Matrix.identity()
|
|
215
|
+
self.referenceFrame = osg.Transform.ABSOLUTE_RF
|
|
216
|
+
self.allowEventFocus = False
|
|
217
|
+
|
|
218
|
+
ss = self.stateSet
|
|
219
|
+
|
|
220
|
+
ss.modes[GL_DEPTH_TEST] = osg.StateAttribute.OFF
|
|
221
|
+
ss.modes[GL_BLEND] = osg.StateAttribute.ON
|
|
222
|
+
ss.attributes[osg.StateAttribute.BLENDFUNC] = (
|
|
223
|
+
osg.BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA), osg.StateAttribute.ON
|
|
224
|
+
)
|
|
225
|
+
ss.uniforms["uProgress"] = 0.0
|
|
226
|
+
ss.uniforms["uFgColor"] = osg.Vec4(*fg_color)
|
|
227
|
+
ss.uniforms["uBgColor"] = osg.Vec4(*bg_color)
|
|
228
|
+
|
|
229
|
+
self._fraction = 0.0
|
|
230
|
+
|
|
231
|
+
def _attach(self, geometry, program):
|
|
232
|
+
# self.stateSet.attributes.append(program)
|
|
233
|
+
# self.children.append(geometry)
|
|
234
|
+
geometry.stateSet.attributes.append(program)
|
|
235
|
+
self.children.append(geometry)
|
|
236
|
+
|
|
237
|
+
def update(self, fraction):
|
|
238
|
+
"""Set the current progress, clamped to [0, 1]."""
|
|
239
|
+
|
|
240
|
+
self._fraction = max(0.0, min(1.0, fraction))
|
|
241
|
+
self.stateSet.uniforms["uProgress"] = self._fraction
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def fraction(self):
|
|
245
|
+
return self._fraction
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _default_to_fraction(update):
|
|
249
|
+
"""Interprets an osgx.gltf.AsyncProgress-shaped (stage, current, total, section, overall)
|
|
250
|
+
update as a 0..1 fraction -- the only progress-source shape this codebase has today. Uses
|
|
251
|
+
`overall` (a monotonic, whole-load estimate computed on the C++ side) rather than
|
|
252
|
+
re-deriving a fraction from current/total, which resets at every section boundary within
|
|
253
|
+
Parsing and would visibly jump backward. Pass a different `to_fraction` to watch() for any
|
|
254
|
+
other shape (a plain float already in [0, 1], a different tuple layout, whatever); this
|
|
255
|
+
default is a convenience, not a contract.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
return update[-1]
|
|
259
|
+
|
|
260
|
+
async def watch(self, progress, poll_interval=1.0 / 60.0, to_fraction=None):
|
|
261
|
+
"""Drives this indicator's fraction from any `.poll()`-shaped progress source (e.g.
|
|
262
|
+
osgx.gltf.AsyncProgress), independently of however the underlying operation is actually
|
|
263
|
+
run. Deliberately NOT wired through run_with_progress()'s on_progress callback -- add
|
|
264
|
+
this as its own coroutine to pyosg_async.run()'s task list instead:
|
|
265
|
+
|
|
266
|
+
asyncio.run(pyosg_async.run(viewer, load(...), bar.watch(progress)))
|
|
267
|
+
|
|
268
|
+
so the load and the display are fully decoupled: `load()` doesn't need to know a bar
|
|
269
|
+
exists (it just runs `progress` through whatever loads it), and this doesn't need to
|
|
270
|
+
know how `load()` runs. Anyone wanting a different display style writes their own
|
|
271
|
+
coroutine of this same shape instead of subclassing anything.
|
|
272
|
+
|
|
273
|
+
Runs forever, polling at `poll_interval`, until cancelled -- normally when the whole
|
|
274
|
+
session ends (see pyosg_async.run()'s docstring: only the window closing ends things,
|
|
275
|
+
so this harmlessly keeps polling a progress object that stopped changing once the load
|
|
276
|
+
it's watching finishes, until then).
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
to_fraction = to_fraction or self._default_to_fraction
|
|
280
|
+
|
|
281
|
+
while True:
|
|
282
|
+
update = progress.poll()
|
|
283
|
+
|
|
284
|
+
if update is not None:
|
|
285
|
+
self.update(to_fraction(update))
|
|
286
|
+
|
|
287
|
+
await asyncio.sleep(poll_interval)
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def fg_color(self):
|
|
291
|
+
return self.stateSet.uniforms["uFgColor"].value
|
|
292
|
+
|
|
293
|
+
@fg_color.setter
|
|
294
|
+
def fg_color(self, value):
|
|
295
|
+
self.stateSet.uniforms["uFgColor"] = osg.Vec4(*value)
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def bg_color(self):
|
|
299
|
+
return self.stateSet.uniforms["uBgColor"].value
|
|
300
|
+
|
|
301
|
+
@bg_color.setter
|
|
302
|
+
def bg_color(self, value):
|
|
303
|
+
self.stateSet.uniforms["uBgColor"] = osg.Vec4(*value)
|
|
304
|
+
|
|
305
|
+
BAR_VERTEX_SHADER = """
|
|
306
|
+
#version 430 core
|
|
307
|
+
|
|
308
|
+
uniform float uHeight = 0.05;
|
|
309
|
+
|
|
310
|
+
out float vU;
|
|
311
|
+
|
|
312
|
+
void main() {
|
|
313
|
+
// Same gl_VertexID-indexed quad-corner trick as pyosg-instanced.py/pyosg-fire.py, but
|
|
314
|
+
// pinned to the bottom of NDC space instead of centered -- a horizontal strip from
|
|
315
|
+
// y=-1 up to y=-1+uHeight, spanning the full width.
|
|
316
|
+
vec2 base[4] = vec2[4](
|
|
317
|
+
vec2(-1.0, -1.0),
|
|
318
|
+
vec2( 1.0, -1.0),
|
|
319
|
+
vec2( 1.0, -1.0 + uHeight),
|
|
320
|
+
vec2(-1.0, -1.0 + uHeight)
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
vec2 v = base[gl_VertexID % 4];
|
|
324
|
+
|
|
325
|
+
vU = (v.x + 1.0) * 0.5;
|
|
326
|
+
|
|
327
|
+
gl_Position = vec4(v, 0.0, 1.0);
|
|
328
|
+
}
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
BAR_FRAGMENT_SHADER = """
|
|
332
|
+
#version 430 core
|
|
333
|
+
|
|
334
|
+
in float vU;
|
|
335
|
+
|
|
336
|
+
uniform float uProgress = 0.0;
|
|
337
|
+
uniform vec4 uFgColor = vec4(0.2, 0.8, 0.3, 1.0);
|
|
338
|
+
uniform vec4 uBgColor = vec4(0.15, 0.15, 0.15, 1.0);
|
|
339
|
+
|
|
340
|
+
out vec4 fragColor;
|
|
341
|
+
|
|
342
|
+
void main() {
|
|
343
|
+
fragColor = vU <= uProgress ? uFgColor : uBgColor;
|
|
344
|
+
}
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
class ProgressBar(Progress):
|
|
348
|
+
"""A left-to-right horizontal progress bar pinned to the bottom of the screen.
|
|
349
|
+
|
|
350
|
+
`bar_height` is a literal pixel thickness (default 2.5x PixelText's native glyph height --
|
|
351
|
+
see NATIVE_TEXT_HEIGHT below), not a window-relative divisor -- unlike the bar's own clip-
|
|
352
|
+
space quad shader, a percentage label needs a REAL pixel size to look right regardless of
|
|
353
|
+
window size (an NDC-relative font would grow/shrink with the window along with the bar), so
|
|
354
|
+
this class converts it to the NDC fraction (`uHeight = 2 * bar_height / height`) itself
|
|
355
|
+
rather than pushing that math onto every caller.
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
# PixelText's font is a fixed 5x7 grid (see osgx::PixelText::GLYPH_ROWS) -- "native" height
|
|
359
|
+
# here means cellSize=GLYPH_ROWS, i.e. one glyph pixel per cellSize unit, the smallest size
|
|
360
|
+
# at which the font is still drawn 1:1 rather than up/down-scaled.
|
|
361
|
+
NATIVE_TEXT_HEIGHT = osgx.PixelText.GLYPH_ROWS
|
|
362
|
+
|
|
363
|
+
def __init__(
|
|
364
|
+
self,
|
|
365
|
+
width,
|
|
366
|
+
height,
|
|
367
|
+
bar_height=2.5 * NATIVE_TEXT_HEIGHT,
|
|
368
|
+
show_percentage=True,
|
|
369
|
+
**kwargs
|
|
370
|
+
):
|
|
371
|
+
super().__init__(width, height, **kwargs)
|
|
372
|
+
|
|
373
|
+
"""
|
|
374
|
+
g = osg.Geode(debug=True, name="GEODE")
|
|
375
|
+
|
|
376
|
+
g.drawables.append(osg.Geometry(debug=True, name="EXPLOSION"))
|
|
377
|
+
|
|
378
|
+
self.children.append(g)
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
g = osg.Geometry()
|
|
382
|
+
|
|
383
|
+
g.primitiveSets.append(osg.DrawArrays(osg.PrimitiveSet.TRIANGLE_FAN, 0, 4))
|
|
384
|
+
# No real vertex data -- positions come entirely from gl_VertexID in the shader, so
|
|
385
|
+
# OSG has nothing to compute a bound from. Set one manually, matching the shader's own
|
|
386
|
+
# clip-space output range, or cull traversal (testing against this Camera's own
|
|
387
|
+
# identity-matrix frustum, which IS exactly the NDC cube) drops this silently.
|
|
388
|
+
g.initialBound = osg.BoundingBox(-1, -1, -1, 1, 1, 1)
|
|
389
|
+
|
|
390
|
+
p = osg.Program(name="pyosg_async.ProgressBar", shaders=(
|
|
391
|
+
osg.Shader(osg.Shader.VERTEX, BAR_VERTEX_SHADER),
|
|
392
|
+
osg.Shader(osg.Shader.FRAGMENT, BAR_FRAGMENT_SHADER)
|
|
393
|
+
))
|
|
394
|
+
|
|
395
|
+
self._attach(g, p)
|
|
396
|
+
|
|
397
|
+
self._width = width
|
|
398
|
+
self._height = height
|
|
399
|
+
self._bar_pixel_height = bar_height
|
|
400
|
+
self.stateSet.uniforms["uHeight"] = 2.0 * bar_height / height
|
|
401
|
+
|
|
402
|
+
self._label = None
|
|
403
|
+
|
|
404
|
+
if show_percentage:
|
|
405
|
+
self._build_label()
|
|
406
|
+
|
|
407
|
+
def _build_label(self):
|
|
408
|
+
"""Adds a "NN%" osgx.PixelText label, white-inked, vertically centered inside the bar
|
|
409
|
+
itself -- as a plain child of THIS Camera (same POST_RENDER subgraph as the bar quad),
|
|
410
|
+
not a second nested Camera. PixelText's vertex shader positions glyphs via the standard
|
|
411
|
+
osg_ModelViewProjectionMatrix; since this Camera's view/projection are both identity
|
|
412
|
+
(see Progress.__init__), that MVP is just whatever Model matrix sits above the label in
|
|
413
|
+
the scene graph -- so `self._label_transform` below IS this label's only "projection",
|
|
414
|
+
reproducing an osg.Matrix.ortho2D(0, width, 0, height) camera (the convention every
|
|
415
|
+
other PixelText call site uses, e.g. pyosg-dice.py's HUD label) as one plain matrix
|
|
416
|
+
instead of a second Camera. See _position_label() for the actual math.
|
|
417
|
+
"""
|
|
418
|
+
|
|
419
|
+
self._label = osgx.PixelText("0%", 1.0 * self.NATIVE_TEXT_HEIGHT)
|
|
420
|
+
self._label.ink = osg.Vec4(1.0, 1.0, 1.0, 1.0)
|
|
421
|
+
|
|
422
|
+
geode = osg.Geode(name="progress-label")
|
|
423
|
+
|
|
424
|
+
geode.drawables.append(self._label)
|
|
425
|
+
|
|
426
|
+
self._label_transform = osg.MatrixTransform()
|
|
427
|
+
|
|
428
|
+
self._label_transform.children.append(geode)
|
|
429
|
+
|
|
430
|
+
self.children.append(self._label_transform)
|
|
431
|
+
|
|
432
|
+
self._position_label()
|
|
433
|
+
|
|
434
|
+
def _position_label(self):
|
|
435
|
+
"""Re-centers the label (both axes) over the bar. Called on every update() since the
|
|
436
|
+
text's width changes as the digit count changes ("0%" vs "100%") -- width is derived
|
|
437
|
+
from cellSize * len(text) rather than PixelText's own bounding box, since advance
|
|
438
|
+
defaults to cellSize (the monospace case) and this avoids depending on a bounding-box
|
|
439
|
+
query that may not be exposed to Python.
|
|
440
|
+
|
|
441
|
+
The label's matrix does two jobs at once, applied in this order (OSG's row-vector
|
|
442
|
+
convention: `v * A * B` applies A first, so the written order below IS the applied
|
|
443
|
+
order): position the label first, in the same pixel units PixelText's cellSize is
|
|
444
|
+
already in, then convert THAT ENTIRE pixel-space placement to clip space in one step --
|
|
445
|
+
exactly what osg.Matrix.ortho2D(0, width, 0, height) does as a projection matrix, just
|
|
446
|
+
folded into this one model matrix instead, so it composes correctly under this Camera's
|
|
447
|
+
identity projection without needing a second Camera to hold a real one.
|
|
448
|
+
"""
|
|
449
|
+
|
|
450
|
+
cell_size = self._label.cellSize
|
|
451
|
+
text_width = cell_size * len(self._label.text)
|
|
452
|
+
x = (self._width - text_width) / 2.0
|
|
453
|
+
y = (self._bar_pixel_height - cell_size) / 2.0
|
|
454
|
+
|
|
455
|
+
self._label_transform.matrix = (
|
|
456
|
+
osg.Matrix.translate(x, y, 0.0) *
|
|
457
|
+
osg.Matrix.scale(2.0 / self._width, 2.0 / self._height, 1.0) *
|
|
458
|
+
osg.Matrix.translate(-1.0, -1.0, 0.0)
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def update(self, fraction):
|
|
462
|
+
super().update(fraction)
|
|
463
|
+
|
|
464
|
+
if self._label is not None:
|
|
465
|
+
self._label.text = f"{round(self._fraction * 100)}%"
|
|
466
|
+
|
|
467
|
+
self._position_label()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
# setdefault(), not update() -- same reason as pyosg_visitor.py: an example that already set its
|
|
6
|
+
# own OSG_WINDOW/OSG_THREADING/etc. (window size, a non-default GL version, ...) before importing
|
|
7
|
+
# this keeps what it set; this only fills in whatever it didn't. Importing this is now the ONE
|
|
8
|
+
# place "SingleThreaded is mandatory" (see feedback_viewer_close_threading_deadlock -- a live
|
|
9
|
+
# non-SingleThreaded draw thread can deadlock a Viewer's destructor) is declared -- an example
|
|
10
|
+
# that imports this for window_size() gets it whether or not its own env block remembers to say
|
|
11
|
+
# so, instead of a standing policy that only holds as well as 42 separate copy-pasted blocks do.
|
|
12
|
+
os.environ.setdefault("OSG_WINDOW", "50 50 800 600")
|
|
13
|
+
os.environ.setdefault("OSG_THREADING", "SingleThreaded")
|
|
14
|
+
os.environ.setdefault("OSG_GL_CONTEXT_PROFILE_MASK", "1")
|
|
15
|
+
os.environ.setdefault("OSG_GL_VERSION", "4.6")
|
|
16
|
+
os.environ.setdefault("OSG_GL_CONTEXT_VERSION", "4.6")
|
|
17
|
+
|
|
18
|
+
# Derives (width, height) from OSG_WINDOW ("x y width height", e.g. "50 50 800 600") instead of a
|
|
19
|
+
# second, separately-hardcoded W, H module constant -- one declared value per file instead of two
|
|
20
|
+
# that can silently drift apart. Standalone __main__ blocks call this; the pyosg/
|
|
21
|
+
# OpenSceneGraph.examples runners never do (they pass their own --width/--height straight into
|
|
22
|
+
# build_scene() and viewer.setUpViewInWindow()). See build_scene()'s own contract comment in
|
|
23
|
+
# pyosg-mrt.py: (w, h) stays an explicit argument on every build_scene(), never something it reads
|
|
24
|
+
# from the environment itself -- this function is the caller-side counterpart of that rule, not an
|
|
25
|
+
# exception to it.
|
|
26
|
+
def window_size(default=(800, 600)):
|
|
27
|
+
spec = os.environ.get("OSG_WINDOW")
|
|
28
|
+
|
|
29
|
+
if not spec:
|
|
30
|
+
return default
|
|
31
|
+
|
|
32
|
+
x, y, w, h = spec.split()
|
|
33
|
+
|
|
34
|
+
return int(w), int(h)
|
|
35
|
+
|
|
36
|
+
# Deliberately here, not at module top -- osg must never be imported before the
|
|
37
|
+
# os.environ.setdefault() block above runs (see that block's own comment), and this module gets
|
|
38
|
+
# imported by every example specifically so those defaults land before ITS OWN internal osg
|
|
39
|
+
# import, let alone the caller's later `from OpenSceneGraph import *`.
|
|
40
|
+
from OpenSceneGraph import osg
|
|
41
|
+
from OpenSceneGraph.GL import GL_DEPTH_TEST
|
|
42
|
+
import osgx
|
|
43
|
+
|
|
44
|
+
# Same technique as pyosg_async.py's ProgressBar._build_label()/_position_label() -- a plain
|
|
45
|
+
# child of an identity-view/projection POST_RENDER Camera, hand-composed translate*scale*translate
|
|
46
|
+
# matrix folding pixel-space placement directly into clip space (osg's row-vector convention, so
|
|
47
|
+
# written left-to-right IS the applied order: position in pixel space first, then map that whole
|
|
48
|
+
# placement into NDC in one step) instead of a second Camera holding a real ortho2D projection.
|
|
49
|
+
# Fixed to the (w, h) passed in, same limitation ProgressBar's label has: build_scene(w, h) has no
|
|
50
|
+
# live viewer to read a real/resized window size from, so this does not track a later resize --
|
|
51
|
+
# pass the SAME (w, h) build_scene() itself received.
|
|
52
|
+
def label(text, w, h, corner="bottom-left", scale=2.0, margin=12.0, ink=(1.0, 1.0, 1.0, 1.0)):
|
|
53
|
+
"""A small screen-space HUD text overlay any example can drop into its scene, e.g. for
|
|
54
|
+
"Press R to reroll"-style on-screen hints. Returns a ready-to-attach osg.Camera -- add it as a
|
|
55
|
+
child anywhere in build_scene()'s returned graph:
|
|
56
|
+
|
|
57
|
+
root.children.append(pyosg_example.label("Press R to reroll", w, h))
|
|
58
|
+
|
|
59
|
+
`scale` is a multiple of osgx.PixelText's native glyph size (GLYPH_ROWS tall, GLYPH_COLS
|
|
60
|
+
wide); the default of 2.0 draws at 2x native resolution, per the user's own request.
|
|
61
|
+
`corner` is one of "bottom-left" (default), "bottom-right", "top-left", "top-right".
|
|
62
|
+
`margin` is a literal pixel gap from the chosen corner's two edges.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
cell_size = osgx.PixelText.GLYPH_ROWS * scale
|
|
66
|
+
pixel_text = osgx.PixelText(text, cell_size)
|
|
67
|
+
|
|
68
|
+
# PixelText's own cellSize (and its default advance) is a SQUARE per-character cell sized
|
|
69
|
+
# off GLYPH_ROWS (see osgx::PixelText::createAtlas()'s own comment: "a glyph block is only
|
|
70
|
+
# GLYPH_COLS * pixelScale wide but GLYPH_ROWS * pixelScale tall" -- the glyph itself sits
|
|
71
|
+
# centered in that square with margin on the narrower axis). Leaving advance at its default
|
|
72
|
+
# spaces characters a full cellSize apart -- visibly wider than the glyph's own native
|
|
73
|
+
# GLYPH_COLS-wide footprint. Tightening it to the glyph's real width gives ordinary,
|
|
74
|
+
# non-monospace-square-cell text spacing instead.
|
|
75
|
+
advance = osgx.PixelText.GLYPH_COLS * scale
|
|
76
|
+
|
|
77
|
+
pixel_text.advance = advance
|
|
78
|
+
pixel_text.ink = osg.Vec4(*ink)
|
|
79
|
+
|
|
80
|
+
geode = osg.Geode(name="hud-label")
|
|
81
|
+
|
|
82
|
+
geode.drawables.append(pixel_text)
|
|
83
|
+
|
|
84
|
+
# Matches PixelText's own internal width formula (see PixelText.cpp's computeBoundingBox()):
|
|
85
|
+
# every glyph but the last only takes `advance` of horizontal room; the last one still needs
|
|
86
|
+
# its own full cellSize, since nothing after it truncates its trailing margin.
|
|
87
|
+
text_width = max(len(text) - 1, 0) * advance + cell_size
|
|
88
|
+
corners = {
|
|
89
|
+
"bottom-left": (margin, margin),
|
|
90
|
+
"bottom-right": (w - margin - text_width, margin),
|
|
91
|
+
"top-left": (margin, h - margin - cell_size),
|
|
92
|
+
"top-right": (w - margin - text_width, h - margin - cell_size),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if corner not in corners:
|
|
96
|
+
raise ValueError(f"label: unknown corner {corner!r} (expected one of {sorted(corners)})")
|
|
97
|
+
|
|
98
|
+
x, y = corners[corner]
|
|
99
|
+
transform = osg.MatrixTransform()
|
|
100
|
+
|
|
101
|
+
transform.matrix = (
|
|
102
|
+
osg.Matrix.translate(x, y, 0.0) *
|
|
103
|
+
osg.Matrix.scale(2.0 / w, 2.0 / h, 1.0) *
|
|
104
|
+
osg.Matrix.translate(-1.0, -1.0, 0.0)
|
|
105
|
+
)
|
|
106
|
+
transform.children.append(geode)
|
|
107
|
+
|
|
108
|
+
camera = osg.Camera(name="hud-label-camera")
|
|
109
|
+
|
|
110
|
+
camera.renderOrder = osg.Camera.POST_RENDER
|
|
111
|
+
camera.clearMask = 0
|
|
112
|
+
camera.referenceFrame = osg.Transform.ABSOLUTE_RF
|
|
113
|
+
camera.viewport = osg.Viewport(0, 0, w, h)
|
|
114
|
+
camera.viewMatrix = osg.Matrix.identity()
|
|
115
|
+
camera.projectionMatrix = osg.Matrix.identity()
|
|
116
|
+
camera.allowEventFocus = False
|
|
117
|
+
camera.stateSet.modes[GL_DEPTH_TEST] = osg.StateAttribute.OFF
|
|
118
|
+
camera.children.append(transform)
|
|
119
|
+
|
|
120
|
+
return camera
|