-
Notifications
You must be signed in to change notification settings - Fork 17
/
rendering.py
1295 lines (1068 loc) · 43.2 KB
/
rendering.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# This file is part of pymadcad, distributed under license LGPL v3
''' Display module of pymadcad
This module provides a render pipeline system featuring:
- Class `Scene` to gather the data to render
- Widget `View` that actually renders the scene
- The display protocol, that allows any object to define its `Display` subclass to be rendered in a scene.
The view is for window integration and user interaction. `Scene` is only to manage the objects to render . Almost all madcad data types can be rendered to scenes being converted into an appropriate subclass of `Display`. Since the conversion from madcad data types into display instance is automatically handled via the *display protocol*, you usually don't need to deal with displays directly.
Display protocol
----------------
A displayable is an object that implements the signature of Display:
class display:
box (Box) # delimiting the display, can be an empty or invalid box
world (fmat4) # local transformation
__getitem__ # access to subdisplays if there is
stack(scene) # rendering routines (can be methods, or any callable)
duplicate(src,dst) # copy the display object for an other scene if possible
update(scene,displayable) # upgrade the current display to represent the given displayable
control(...) # handle events
For more details, see class Display below
WARNING
-------
As the GPU native precision is f4 (float 32 bits), all the vector stuff regarding rendering is made using simple precision types: `fvec3, fvec4, fmat3, fmat4, ...`
NOTE
----
There is some restrictions using the widget. This is due to some Qt limitations (and design choices), that Qt is using separated opengl contexts for each independent widgets or window.
- a View should not be reparented once displayed
- a View can't share a scene with Views from an other window
- to share a Scene between Views, you must activate
QCoreApplication.setAttribute(Qt.AA_ShareOpenGLContexts)
'''
import traceback
from copy import copy, deepcopy
from operator import itemgetter
import moderngl as mgl
import numpy.core as np
from PIL import Image
from PyQt5.QtCore import QEvent, QPoint, Qt
from PyQt5.QtGui import (QFocusEvent, QInputEvent, QKeyEvent, QMouseEvent,
QSurfaceFormat, QTouchEvent)
from PyQt5.QtWidgets import QApplication, QOpenGLWidget, QWidget
from . import settings
from .common import resourcedir
from .mathutils import *
from .nprint import nprint
# minimum opengl version required by the rendering pipeline
opengl_version = (3,3)
# shared open gl context, None if not yet initialized
global_context = None
def qt_surface_format():
fmt = QSurfaceFormat()
fmt.setVersion(*opengl_version)
fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile)
fmt.setSamples(4)
return fmt
def show(scene:dict, interest:Box=None, size=uvec2(400,400), projection=None, navigation=None, **options):
'''
Easy and convenient way to create a window containing a `View` on a created `Scene`
If a Qt app is not already running, the functions returns when the window has been closed and all GUI destroyed
Parameters:
scene: a mapping (dict or list) giving the objects to render in the scene
interest: the region of interest to zoom on at the window initialization
size: the window size (pixel)
options: options to set in `Scene.options`
Tip:
For integration in a Qt window or to manipulate the view, you should directly use `View`
'''
global global_context
if isinstance(scene, list): scene = dict(enumerate(scene))
# retro-compatibility fix, shall be removed in future versions
if 'options' in options: options.update(options['options'])
if not isinstance(scene, Scene): scene = Scene(scene, options)
QSurfaceFormat.setDefaultFormat(qt_surface_format())
app = QApplication.instance()
created = False
if not app:
import sys
QApplication.setAttribute(Qt.AA_ShareOpenGLContexts, True)
app = QApplication(sys.argv)
global_context = None
created = True
# use the Qt color scheme if specified
if settings.display['system_theme']:
settings.use_qt_colors()
# create the scene as a window
view = View(scene, projection=projection, navigation=navigation)
view.resize(*size)
view.show()
# make the camera see everything
if not interest:
interest = view.scene.box()
view.center(interest.center)
view.adjust(interest)
if created:
err = app.exec()
if err != 0: print('error: Qt exited with code', err)
def render(scene, options=None, interest:Box=None, navigation=None, projection=None, size=uvec2(400,400)):
''' Shortcut to render the given objects to an image, returns a PIL Image
For repeated renderings or view manipualtion, you should directly use `Offscreen`
NOTE:
the system theme colors cannot be automatically loaded since no running QApplication is assumed in the function
'''
if isinstance(scene, list): scene = dict(enumerate(scene))
if not isinstance(scene, Scene): scene = Scene(scene, options)
# create the scene and an offscreen renderer
view = Offscreen(scene, size, navigation=navigation, projection=projection)
# load objects in the scene, so the scene's box can be computed
with scene.ctx:
scene.dequeue()
# make the camera see everything
if not navigation:
if not interest:
interest = view.scene.box()
view.center(interest.center)
view.adjust(interest)
return view.render()
class Display:
''' Blanket implementation for displays.
This class signature is exactly the display protocol specification
Attributes:
world(fmat4): matrix from local space to parent space
box(Box): boudingbox of the display in local space
These attributes are variable members by default but can be overriden as properties if needed.
'''
# mendatory part of the protocol
box = Box(center=0, width=fvec3(-inf)) # to inform the scene and the view of the object size
world = fmat4(1) # set by the display containing this one if it is belonging to a group
def display(self, scene) -> 'self':
''' Displays are obviously displayable as themselves '''
return self
def stack(self, scene) -> '[(key, target, priority, callable)]':
''' Rendering functions to insert in the renderpipeline.
The expected result can be any iterable providing tuples `(key, target, priority, callable)` such as:
:key: a tuple with the successive keys in the displays tree, most of the time implementers set it to `()` because it doesn't belong to a subpart of the Display.
:target: the name of the render target in the view that will be rendered (see View)
:priority: a float that is used to insert the callable at the proper place in the rendering stack
:callable: a function that renders, signature is `func(view)`
The view contains the uniforms, rendering targets and the scene for common resources
'''
return ()
def duplicate(self, src, dst) -> 'display/None':
''' Duplicate the display for an other scene (other context) but keeping the same memory buffers when possible.
Return None if not possible or not implemented.
'''
return None
def __getitem__(self, key) -> 'display':
''' Get a subdisplay by its index/key in this display (like in a scene) '''
raise IndexError('{} has no sub displays'.format(type(self).__name__))
def update(self, scene, displayable) -> bool:
''' Update the current displays internal datas with the given displayable .
If the display cannot be upgraded, it must return False to be replaced by a fresh new display created from the displayable
'''
return False
# optional part for usage with Qt
selected = False
def control(self, view, key, sub, evt: 'QEvent'):
''' Handle input events occuring on the area of this display (or of one of its subdisplay).
For subdisplay events, the parents control functions are called first, and the sub display controls are called only if the event is not accepted by parents
Parameters:
key: the key path for the current display
sub: the key path for the subdisplay
evt: the Qt event (see Qt doc)
'''
pass
def qt_2_glm(v):
if isinstance(v, (QPoint, QPointF)): return vec2(v.x(), v.y())
elif isinstance(v, (QSize, QSizeF)): return vec2(v.width(), v.height())
else:
raise TypeError("can't convert {} to vec2".format(type(v).__name__))
def navigation_tool(dispatcher, view):
''' Internal navigation tool '''
ctrl = alt = slow = False
nav = curr = None
moving = False
hastouched = False
while True:
evt = yield
if isinstance(evt, QKeyEvent):
k = evt.key()
press = evt.type() == QEvent.KeyPress
if k == Qt.Key_Control: ctrl = press
elif k == Qt.Key_Alt: alt = press
elif k == Qt.Key_Shift: slow = press
elif evt.type() == QEvent.MouseButtonPress:
modifiers = QApplication.queryKeyboardModifiers()
ctrl = bool(modifiers & Qt.ControlModifier)
alt = bool(modifiers & Qt.AltModifier)
shift = bool(modifiers & Qt.ShiftModifier)
if isinstance(evt, QKeyEvent) or evt.type() == QEvent.MouseButtonPress:
if ctrl and alt: curr = 'zoom'
elif ctrl: curr = 'pan'
elif alt: curr = 'rotate'
else: curr = None
if isinstance(evt, QKeyEvent):
evt.ignore() # ignore the keys to pass shortcuts to parents
elif evt.type() == QEvent.MouseButtonPress:
last = evt.pos()
if evt.button() == Qt.MiddleButton:
nav = 'rotate'
else:
nav = curr
# prevent any scene interaction
if nav:
evt.accept()
elif evt.type() == QEvent.MouseMove:
if nav:
moving = True
gap = evt.pos() - last
dx = gap.x()/view.height()
dy = gap.y()/view.height()
if nav == 'pan': view.navigation.pan(dx, dy)
elif nav == 'rotate': view.navigation.rotate(dx, dy, 0)
elif nav == 'zoom':
middle = QPoint(view.width(), view.height())/2
f = ( (last-middle).manhattanLength()
/ (evt.pos()-middle).manhattanLength() )
view.navigation.zoom(f)
last = evt.pos()
view.update()
evt.accept()
elif evt.type() == QEvent.MouseButtonRelease:
if moving:
moving = False
evt.accept()
elif evt.type() == QEvent.Wheel:
view.navigation.zoom(exp(-evt.angleDelta().y()/(8*90))) # the 8 factor is there because of the Qt documentation
view.update()
evt.accept()
elif isinstance(evt, QTouchEvent):
nav = None
pts = evt.touchPoints()
# view rotation
if len(pts) == 2:
startlength = (pts[0].lastPos()-pts[1].lastPos()).manhattanLength()
zoom = startlength / (pts[0].pos()-pts[1].pos()).manhattanLength()
displt = ( (pts[0].pos()+pts[1].pos()) /2
- (pts[0].lastPos()+pts[1].lastPos()) /2 ) /view.height()
dc = pts[0].pos() - pts[1].pos()
dl = pts[0].lastPos() - pts[1].lastPos()
rot = atan2(dc.y(), dc.x()) - atan2(dl.y(), dl.x())
view.navigation.zoom(zoom)
view.navigation.rotate(displt.x(), displt.y(), rot)
hastouched = True
view.update()
evt.accept()
# view translation
elif len(pts) == 3:
lc = ( pts[0].lastPos()
+ pts[1].lastPos()
+ pts[2].lastPos()
)/3
lr = ( (pts[0].lastPos() - lc) .manhattanLength()
+ (pts[1].lastPos() - lc) .manhattanLength()
+ (pts[2].lastPos() - lc) .manhattanLength()
)/3
cc = ( pts[0].pos()
+ pts[1].pos()
+ pts[2].pos()
)/3
cr = ( (pts[0].pos() - cc) .manhattanLength()
+ (pts[1].pos() - cc) .manhattanLength()
+ (pts[2].pos() - cc) .manhattanLength()
)/3
zoom = lr / cr
displt = (cc - lc) /view.height()
view.navigation.zoom(zoom)
view.navigation.pan(displt.x(), displt.y())
hastouched = True
view.update()
evt.accept()
# finish a gesture
elif evt.type() in (QEvent.TouchEnd, QEvent.TouchUpdate):
evt.accept()
class Turntable:
''' Navigation rotating on yaw and pitch around a center
Object used as `View.navigation`
'''
def __init__(self, center:fvec3=0, distance:float=1, yaw:float=0, pitch:float=0):
self.center = fvec3(center)
self.yaw = yaw
self.pitch = pitch
self.distance = distance
self.tool = navigation_tool
def rotate(self, dx, dy, dz):
if abs(self.pitch) > 3.2: dx = -dx
self.yaw += dx*pi
self.pitch += dy*pi
if self.pitch > pi: self.pitch -= 2*pi
if self.pitch < -pi: self.pitch += 2*pi
def pan(self, dx, dy):
mat = transpose(fmat3(inverse(fquat(fvec3(pi/2-self.pitch, 0, -self.yaw)))))
self.center += ( mat[0] * -dx + mat[1] * dy) * self.distance/2
def zoom(self, f):
self.distance *= f
def matrix(self) -> fmat4:
# build rotation from view euler angles
rot = inverse(fquat(fvec3(pi/2-self.pitch, 0, -self.yaw)))
mat = translate(fmat4(rot), -self.center)
mat[3][2] -= self.distance
return mat
class Orbit:
''' Navigation rotating on the 3 axis around a center.
Object used as `View.navigation`
'''
def __init__(self, center:fvec3=0, distance:float=1, orient:fvec3=fvec3(1,0,0)):
self.center = fvec3(center)
self.distance = float(distance)
self.orient = fquat(orient)
self.tool = navigation_tool
def rotate(self, dx, dy, dz):
# rotate from view euler angles
self.orient = inverse(fquat(fvec3(-dy, -dx, dz) * pi)) * self.orient
def pan(self, dx, dy):
x,y,z = transpose(fmat3(self.orient))
self.center += (fvec3(x) * -dx + fvec3(y) * dy) * self.distance/2
def zoom(self, f):
self.distance *= f
def matrix(self) -> fmat4:
mat = translate(fmat4(self.orient), -self.center)
mat[3][2] -= self.distance
return mat
class Perspective:
''' Object used as `View.projection`
Attributes:
fov (float): field of view (rad), defaulting to `settings.display['field_of_view']`
'''
def __init__(self, fov=None):
self.fov = fov or settings.display['field_of_view']
def matrix(self, ratio, distance) -> fmat4:
return perspective(self.fov, ratio, distance*1e-2, distance*1e4)
class Orthographic:
''' Object used as `View.projection`
Attributes:
size (float):
factor between the distance from camera to navigation center and the zone size to display
defaulting to `tan(settings.display['field_of_view']/2)`
'''
def __init__(self, size=None):
self.size = size or tan(settings.display['field_of_view']/2)
def matrix(self, ratio, distance) -> fmat4:
return fmat4(1/(ratio*distance*self.size), 0, 0, 0,
0, 1/(distance*self.size), 0, 0,
0, 0, -2/(distance*(1e3-1e-2)), 0,
0, 0, -(1e3+1e-2)/(1e3-1e-2), 1)
class Scene:
''' Rendering pipeline for madcad displayable objects
This class is gui-agnostic, it only relies on OpenGL, and the context has to be created by the user.
When an object is added to the scene, a Display is not immediately created for it, the object is put into the queue and the Display is created at the next render.
If the object is removed from the scene before the next render, it is dequeued.
Attributes:
ctx: moderngl Context (must be the same for all views using this scene)
resources (dict): dictionary of scene resources (like textures, shaders, etc) index by name
options (dict): dictionary of options for rendering, initialized with a copy of `settings.scene`
displays (dict): dictionary of items in the scheme `{'name': Display}`
stacks (list): lists of callables to render each target `{'target': [(key, priority, callable(view))]}`
setup (dict): setup of each rendering target `{'target': callable}`
touched (bool): flag set to True if the stack must be recomputed at the next render time (there is a change in a Display or in one of its children)
'''
def __init__(self, objs=(), options=None, ctx=None, setup=None):
# context variables
self.ctx = ctx
self.resources = {} # context-related resources, shared across displays, but not across contexts (shaders, vertexarrays, ...)
# rendering options
self.options = deepcopy(settings.scene)
if options: self.options.update(options)
# render elements
self.queue = {} # list of objects to display, not yet loaded on the GPU
self.displays = {} # displays created from the inserted objects, associated to their insertion key
self.stacks = {} # dict of list of callables, that constitute the render pipeline: (key, priority, callable)
self.setup = setup or {} # callable for each target
self.touched = False
self.update(objs)
# methods to manage the rendering pipeline
def add(self, displayable, key=None) -> 'key':
''' Add a displayable object to the scene, if key is not specified, an unused integer key is used
The object is not added to the render pipeline yet, but queued for next rendering.
'''
if key is None:
for i in range(len(self.displays)+len(self.queue)+1):
if i not in self.displays and i not in self.queue: key = i
self.queue[key] = displayable
return key
def __setitem__(self, key, value):
''' Equivalent with self.add with a key '''
self.queue[key] = value
def __getitem__(self, key) -> 'display':
''' Get the displayable for the given key, raise when there is no object or when the object is still in queue. '''
return self.displays[key]
def __delitem__(self, key):
''' Remove an item from the scene, at the root level '''
if key in self.displays:
del self.displays[key]
if key in self.queue:
del self.queue[key]
for stack in self.stacks.values():
for i in reversed(range(len(stack))):
if stack[i][0][0] == key:
stack.pop(i)
def item(self, key):
''' Get the Display associated with the given key, descending the parenting tree
The parents must all make their children accessible via `__getitem__`
'''
disp = self.displays
for i in range(1,len(key)):
disp = disp[key[i-1]]
return disp
def update(self, objs:dict):
''' Rebuild the scene from a dictionary of displayables
Update former displays if possible instead of replacing it
'''
self.queue.update(objs)
self.touch()
def sync(self, objs:dict):
''' Update the scene from a dictionary of displayables, the former values that cannot be updated are discarded '''
for key in list(self.displays):
if key not in objs:
del self.displays[key]
self.update(objs)
def touch(self):
''' Shorthand for `self.touched = True` '''
self.touched = True
def dequeue(self):
''' Load all pending objects to insert into the scene.
This is called automatically by the next `render()` if `touch()` has been called
'''
if self.queue:
with self.ctx:
self.ctx.finish()
# update displays
for key,displayable in self.queue.items():
try:
self.displays[key] = self.display(displayable, self.displays.get(key))
except:
print('\ntried to display', object.__repr__(displayable))
traceback.print_exc()
self.touched = True
self.queue.clear()
if self.touched:
self.restack()
def restack(self):
''' Update the rendering calls stack from the current scene's displays.
This is called automatically on `dequeue()`
'''
# recreate stacks
for stack in self.stacks.values():
stack.clear()
for key,display in self.displays.items():
for frame in display.stack(self):
if len(frame) != 4:
raise ValueError('wrong frame format in the stack from {}\n\t got {}'.format(display, frame))
sub,target,priority,func = frame
if target not in self.stacks: self.stacks[target] = []
stack = self.stacks[target]
stack.append(((key,*sub), priority, func))
# sort the stack using the specified priorities
for stack in self.stacks.values():
stack.sort(key=itemgetter(1))
self.touched = False
def render(self, view):
''' Render to the view targets.
This must be called by the view widget, once the OpenGL context is set.
'''
empty = ()
with self.ctx:
# apply changes that need opengl runtime
self.dequeue()
# render everything
for target, frame, setup in view.targets:
view.target = frame
frame.use()
frame.clear()
setup()
for key, priority, func in self.stacks.get(target,empty):
func(view)
def box(self):
''' Computes the boundingbox of the scene, with the current object poses '''
box = Box(center=fvec3(0), width=fvec3(-inf))
for display in self.displays.values():
box.union_update(display.box.transform(display.world))
return box
def resource(self, name, func=None):
''' Get a resource loaded or load it using the function func.
If func is not provided, an error is raised
'''
if name in self.resources:
return self.resources[name]
elif callable(func):
with self.ctx as ctx: # set the scene context as current opengl context
res = func(self)
self.resources[name] = res
return res
else:
raise KeyError("resource {} doesn't exist or is not loaded".format(repr(name)))
def display(self, obj, former=None):
''' Create a display for the given object for the current scene.
This is the actual function converting objects into displays.
You don't need to call this method if you just want to add an object to the scene, use add() instead
'''
if former and former.update(self, obj):
return former
if type(obj) in overrides:
disp = overrides[type(obj)](self, obj)
elif hasattr(obj, 'display'):
if isinstance(obj.display, type):
disp = obj.display(self, obj)
elif callable(obj.display):
disp = obj.display(self)
else:
raise TypeError("member 'display' must be a method or a type, on {}".format(type(obj).__name__))
else:
raise TypeError('type {} is not displayable'.format(type(obj).__name__))
if not isinstance(disp, Display):
raise TypeError('the display for {} is not a subclass of Display: {}'.format(type(obj).__name__, type(disp)))
return disp
def displayable(obj):
''' Return True if the given object has the matching signature to be added to a Scene '''
return type(obj) in overrides or hasattr(obj, 'display') and callable(obj.display) and not isinstance(obj, type)
class Step(Display):
''' Simple display holding a rendering stack step
`Step(target, priority, callable)`
'''
__slots__ = 'step',
def __init__(self, target, priority, callable): self.step = ((), target, priority, callable)
def __repr__(self): return '{}({}, {}, {})'.format(type(self).__name__, self.step[1], self.step[2], self.step[3])
def stack(self, scene): return self.step,
class Displayable:
''' Simple displayable initializing the given Display class with arguments
At the display creation time, it will simply execute `build(*args, **kwargs)`
'''
__slots__ = 'build', 'args', 'kwargs'
def __init__(self, build, *args, **kwargs):
self.args, self.kwargs = args, kwargs
self.build = build
def __repr__(self):
return '{}({}, {}, {})'.format(type(self).__name__, repr(self.args[1:-1]), repr(self.kwargs)[1:-1])
def display(self, scene):
return self.build(scene, *self.args, **self.kwargs)
def writeproperty(func):
''' Decorator to create a property that has only an action on variable write '''
fieldname = '_'+func.__name__
def getter(self): return getattr(self, fieldname)
def setter(self, value):
setattr(self, fieldname, value)
func(self, value)
return property(getter, setter, doc=func.__doc__)
class Group(Display):
''' A group is like a subscene '''
def __init__(self, scene, objs:'dict/list'=None, local=1):
self._world = fmat4(1)
self._local = fmat4(local)
self.displays = {}
if objs: self.dequeue(scene, objs)
def __getitem__(self, key):
return self.displays[key]
def __iter__(self):
return iter(self.displays.values())
def update(self, scene, objs):
if isinstance(objs, dict): objs = objs
elif hasattr(objs, 'keys'): objs = dict(objs)
elif hasattr(objs, '__iter__'): objs = dict(enumerate(objs))
else:
return False
# update displays
sub = self._world * self._local
with scene.ctx:
scene.ctx.finish()
for key, obj in objs.items():
if not displayable(obj): continue
try:
self.displays[key] = disp = scene.display(obj, self.displays.get(key))
disp.world = sub
except:
print('tried to display', object.__repr__(obj))
traceback.print_exc()
for key in self.displays.keys() - objs.keys():
del self.displays[key]
scene.touch()
return True
dequeue = update
def stack(self, scene):
for key,display in self.displays.items():
for sub,target,priority,func in display.stack(scene):
yield ((key, *sub), target, priority, func)
@writeproperty
def local(self, pose):
''' Pose of the group relatively to its parents '''
sub = self._world * self._local
for display in self.displays.values():
display.world = sub
@writeproperty
def world(self, world):
''' Update children's world matrix applying the current pose in addition to world '''
sub = self._world * self._local
for display in self.displays.values():
display.world = sub
@property
def box(self):
''' Computes the boundingbox of the scene, with the current object poses '''
box = Box(center=fvec3(0), width=fvec3(-inf))
for display in self.displays.values():
box.union_update(display.box)
return box.transform(self._local)
# dictionary to store procedures to override default object displays
overrides = {
list: Group,
dict: Group,
}
class ViewCommon:
''' Common base for Qt's View rendering and Offscreen rendering. It provides common methods to render and interact with a view.
You should always use one of its subclass.
'''
def __init__(self, scene, projection=None, navigation=None):
# interaction methods
self.projection = projection or globals()[settings.scene['projection']]()
self.navigation = navigation or globals()[settings.controls['navigation']]()
# render parameters
self.scene = scene if isinstance(scene, Scene) else Scene(scene)
self.uniforms = {'proj':fmat4(1), 'view':fmat4(1), 'projview':fmat4(1)} # last frame rendering constants
self.targets = []
self.steps = []
self.step = 0
self.stepi = 0
# dump targets
self.map_depth = None
self.map_idents = None
self.fresh = set() # set of refreshed internal variables since the last render
# -- internal frame system --
def refreshmaps(self):
''' Load the rendered frames from the GPU to the CPU
- When a picture is used to GPU rendering it's called 'frame'
- When it is dumped to the RAM we call it 'map' in this library
'''
if 'fb_ident' not in self.fresh:
self.makeCurrent() # set the scene context as current opengl context
with self.scene.ctx as ctx:
#ctx.finish()
self.fb_ident.read_into(self.map_ident, viewport=self.fb_ident.viewport, components=2)
self.fb_ident.read_into(self.map_depth, viewport=self.fb_ident.viewport, components=1, attachment=-1, dtype='f4')
self.fresh.add('fb_ident')
#from PIL import Image
#Image.fromarray(self.map_ident*16, 'I;16').show()
def render(self):
# prepare the view uniforms
w, h = self.fb_screen.size
self.uniforms['view'] = view = self.navigation.matrix()
self.uniforms['proj'] = proj = self.projection.matrix(w/h if h > 0 else 0, self.navigation.distance)
self.uniforms['projview'] = proj * view
self.fresh.clear()
# call the render stack
self.scene.render(self)
def identstep(self, nidents):
''' Updates the amount of rendered idents and return the start ident for the calling rendering pass?
Method to call during a renderstep
'''
s = self.step
self.step += nidents
self.steps[self.stepi] = self.step-1
self.stepi += 1
return s
def setup_ident(self):
# steps for fast fast search of displays with the idents
self.stepi = 0
self.step = 1
if 'ident' in self.scene.stacks and len(self.scene.stacks['ident']) != len(self.steps):
self.steps = [0] * len(self.scene.stacks['ident'])
# ident rendering setup
ctx = self.scene.ctx
ctx.multisample = False
ctx.enable_only(mgl.DEPTH_TEST)
ctx.blend_func = mgl.ONE, mgl.ZERO
ctx.blend_equation = mgl.FUNC_ADD
self.target.clear(0)
def setup_screen(self):
# screen rendering setup
ctx = self.scene.ctx
ctx.multisample = True
ctx.enable_only(mgl.BLEND | mgl.DEPTH_TEST)
ctx.blend_func = mgl.SRC_ALPHA, mgl.ONE_MINUS_SRC_ALPHA
ctx.blend_equation = mgl.FUNC_ADD
background = settings.display['background_color']
if len(background) == 3:
self.target.clear(*background, alpha=1)
elif len(background) == 4:
self.target.clear(*background)
else:
raise ValueError(f"background_color must be a RGB or RGBA tuple, currently {background}")
def preload(self):
''' Internal method to load common resources '''
ctx, resources = self.scene.ctx, self.scene.resources
resources['shader_ident'] = ctx.program(
vertex_shader=open(resourcedir+'/shaders/object-ident.vert').read(),
fragment_shader=open(resourcedir+'/shaders/ident.frag').read(),
)
resources['shader_subident'] = ctx.program(
vertex_shader=open(resourcedir+'/shaders/object-item-ident.vert').read(),
fragment_shader=open(resourcedir+'/shaders/ident.frag').read(),
)
# -- methods to deal with the view --
def somenear(self, point: ivec2, radius=None) -> ivec2:
''' Return the closest coordinate to coords, (within the given radius) for which there is an object at
So if objnear is returning something, objat and ptat will return something at the returned point
'''
if radius is None:
radius = settings.controls['snap_dist']
self.refreshmaps()
for x,y in snailaround(point, (self.map_ident.shape[1], self.map_ident.shape[0]), radius):
ident = int(self.map_ident[-y, x])
if ident:
return uvec2(x,y)
def ptat(self, point: ivec2) -> fvec3:
''' Return the point of the rendered surfaces that match the given window coordinates '''
self.refreshmaps()
viewport = self.fb_ident.viewport
depthred = float(self.map_depth[-point.y,point.x])
x = (point.x/viewport[2] *2 -1)
y = -(point.y/viewport[3] *2 -1)
if depthred == 1.0:
return None
else:
view = self.uniforms['view']
proj = self.uniforms['proj']
a,b = proj[2][2], proj[3][2]
depth = b/(depthred + a) * 0.5 # TODO get the true depth (can't get why there is a strange factor ... opengl trick)
#near, far = self.projection.limits or settings.display['view_limits']
#depth = 2 * near / (far + near - depthred * (far - near))
#print('depth', depth, depthred)
return vec3(fvec3(affineInverse(view) * fvec4(
depth * x /proj[0][0],
depth * y /proj[1][1],
-depth,
1)))
def ptfrom(self, point: ivec2, center: fvec3) -> fvec3:
''' 3D point below the cursor in the plane orthogonal to the sight, with center as origin '''
view = self.uniforms['view']
proj = self.uniforms['proj']
viewport = self.fb_ident.viewport
x = (point.x/viewport[2] *2 -1)
y = -(point.y/viewport[3] *2 -1)
depth = (view * fvec4(fvec3(center),1))[2]
return vec3(fvec3(affineInverse(view) * fvec4(
-depth * x /proj[0][0],
-depth * y /proj[1][1],
depth,
1)))
def itemat(self, point: ivec2) -> 'key':
''' Return the key path of the object at the given screen position (widget relative).
If no object is at this exact location, None is returned
'''
self.refreshmaps()
point = uvec2(point)
ident = int(self.map_ident[-point.y, point.x])
if ident and 'ident' in self.scene.stacks:
rdri = bisect(self.steps, ident)
if rdri == len(self.steps):
print('internal error: object ident points out of idents list')
while rdri > 0 and self.steps[rdri-1] == ident: rdri -= 1
if rdri > 0: subi = ident - self.steps[rdri-1] - 1
else: subi = ident - 1
if rdri >= len(self.scene.stacks['ident']):
print('wrong identification index', ident, self.scene.stacks['ident'][-1])
nprint(self.scene.stacks['ident'])
return
return (*self.scene.stacks['ident'][rdri][0], subi)
# -- view stuff --
def look(self, position: fvec3=None):
''' Make the scene navigation look at the position.
This is changing the camera direction, center and distance.
'''
if not position: position = self.scene.box().center
dir = position - fvec3(affineInverse(self.navigation.matrix())[3])
if not dot(dir,dir) > 1e-6 or not isfinite(position): return
if isinstance(self.navigation, Turntable):
self.navigation.yaw = atan2(dir.x, dir.y)
self.navigation.pitch = -atan2(dir.z, length(dir.xy))
self.navigation.center = position
self.navigation.distance = length(dir)
elif isinstance(self.navigation, Orbit):
focal = self.orient * fvec3(0,0,1)
self.navigation.orient = quat(dir, focal) * self.navigation.orient
self.navigation.center = position
self.navigation.distance = length(dir)
else:
raise TypeError("navigation {} is not supported by 'look'".format(type(self.navigation)))
def adjust(self, box:Box=None):
''' Make the navigation camera large enough to get the given box in .
This is changing the zoom level
'''
if not box: box = self.scene.box()
if box.isempty(): return
# get the most distant point to the focal axis
invview = affineInverse(self.navigation.matrix())
camera, look = fvec3(invview[3]), fvec3(invview[2])
dist = length(noproject(box.center-camera, look)) + max(glm.abs(box.width))/2 * 1.1
if not dist > 1e-6: return
# adjust navigation distance
if isinstance(self.projection, Perspective):
self.navigation.distance = dist / tan(self.projection.fov/2)
elif isinstance(self.projection, Orthographic):
self.navigation.distance = dist / self.projection.size
else:
raise TypeError('projection {} not supported'.format(type(self.projection)))
def center(self, center: fvec3=None):
''' Relocate the navigation to the given position .
This is translating the camera.
'''
if not center: center = self.scene.box().center
if not isfinite(center): return
self.navigation.center = center
class Offscreen(ViewCommon):
''' Object allowing to perform offscreen rendering, navigate and get information from screen as for a normal window
'''
def __init__(self, scene, size=uvec2(400,400), projection=None, navigation=None):
global global_context
super().__init__(scene, projection=projection, navigation=navigation)
if global_context:
self.scene.ctx = global_context
else:
self.scene.ctx = global_context = mgl.create_standalone_context(requires=opengl_version)
self.scene.ctx.line_width = settings.display["line_width"]
self.init(size)
self.preload()
def init(self, size):
w, h = size
ctx = self.scene.ctx
assert ctx, 'context is not initialized'
# self.fb_frame is already created and sized by Qt
self.fb_screen = ctx.simple_framebuffer(size)
self.fb_ident = ctx.simple_framebuffer(size, components=3, dtype='f1')
self.targets = [ ('screen', self.fb_screen, self.setup_screen),
('ident', self.fb_ident, self.setup_ident)]
self.map_ident = np.empty((h,w), dtype='u2')
self.map_depth = np.empty((h,w), dtype='f4')
@property