shaderflow 0.4.0.dev1__py2.py3-none-any.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.
Potentially problematic release.
This version of shaderflow might be problematic. Click here for more details.
- ShaderFlow/Common/Imgui.py +0 -0
- ShaderFlow/Common/Notes.py +134 -0
- ShaderFlow/Common/__init__.py +0 -0
- ShaderFlow/Message.py +147 -0
- ShaderFlow/Module.py +162 -0
- ShaderFlow/Modules/Audio.py +431 -0
- ShaderFlow/Modules/Bouncing.py +94 -0
- ShaderFlow/Modules/Camera.py +451 -0
- ShaderFlow/Modules/Depth.py +0 -0
- ShaderFlow/Modules/Dynamics.py +288 -0
- ShaderFlow/Modules/Frametimer.py +79 -0
- ShaderFlow/Modules/Keyboard.py +43 -0
- ShaderFlow/Modules/Noise.py +80 -0
- ShaderFlow/Modules/OpticalFlow.py +1 -0
- ShaderFlow/Modules/Piano.py +362 -0
- ShaderFlow/Modules/Spectrogram.py +315 -0
- ShaderFlow/Modules/Video.py +234 -0
- ShaderFlow/Modules/Waveform.py +100 -0
- ShaderFlow/Modules/__init__.py +0 -0
- ShaderFlow/Resources/Images/ShaderFlow.png +0 -0
- ShaderFlow/Resources/Images/ShaderFlow.svg +1 -0
- ShaderFlow/Resources/Scenes/Examples/Demo.py +284 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Bars.frag +20 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Bouncing.frag +50 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Life/Simulation.glsl +49 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Life/Visuals.glsl +31 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/MotionBlur.frag +20 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Multipass.frag +43 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/RayMarch.frag +59 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/ShaderToy.frag +65 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Video.frag +5 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Visualizer.frag +75 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Waveform.frag +18 -0
- ShaderFlow/Resources/Shaders/Fragment/Default.glsl +65 -0
- ShaderFlow/Resources/Shaders/Fragment/Final.glsl +7 -0
- ShaderFlow/Resources/Shaders/Fragment/Loading.frag +40 -0
- ShaderFlow/Resources/Shaders/Fragment/Missing.glsl +23 -0
- ShaderFlow/Resources/Shaders/Include/Camera.glsl +166 -0
- ShaderFlow/Resources/Shaders/Include/Complex.glsl +0 -0
- ShaderFlow/Resources/Shaders/Include/ShaderFlow.glsl +444 -0
- ShaderFlow/Resources/Shaders/Vertex/Default.glsl +21 -0
- ShaderFlow/Scene.py +1124 -0
- ShaderFlow/Shader.py +391 -0
- ShaderFlow/Texture.py +528 -0
- ShaderFlow/Variable.py +128 -0
- ShaderFlow/__init__.py +11 -0
- ShaderFlow/__main__.py +22 -0
- shaderflow-0.4.0.dev1.dist-info/METADATA +79 -0
- shaderflow-0.4.0.dev1.dist-info/RECORD +51 -0
- shaderflow-0.4.0.dev1.dist-info/WHEEL +5 -0
- shaderflow-0.4.0.dev1.dist-info/entry_points.txt +2 -0
ShaderFlow/Scene.py
ADDED
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import inspect
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
from abc import abstractmethod
|
|
6
|
+
from collections import deque
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from subprocess import PIPE
|
|
9
|
+
from time import perf_counter
|
|
10
|
+
from typing import (
|
|
11
|
+
Annotated,
|
|
12
|
+
Any,
|
|
13
|
+
Deque,
|
|
14
|
+
Dict,
|
|
15
|
+
Iterable,
|
|
16
|
+
List,
|
|
17
|
+
Optional,
|
|
18
|
+
Self,
|
|
19
|
+
Tuple,
|
|
20
|
+
Union,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
import glfw
|
|
24
|
+
import imgui
|
|
25
|
+
import moderngl
|
|
26
|
+
import PIL
|
|
27
|
+
import tqdm
|
|
28
|
+
from attr import Factory, define, field
|
|
29
|
+
from dotmap import DotMap
|
|
30
|
+
from moderngl_window.context.base import BaseWindow as ModernglWindow
|
|
31
|
+
from moderngl_window.integrations.imgui import ModernglWindowRenderer as ModernglImgui
|
|
32
|
+
from typer import Option
|
|
33
|
+
|
|
34
|
+
import Broken
|
|
35
|
+
from Broken import (
|
|
36
|
+
BrokenEnum,
|
|
37
|
+
BrokenPath,
|
|
38
|
+
BrokenResolution,
|
|
39
|
+
BrokenScheduler,
|
|
40
|
+
BrokenTask,
|
|
41
|
+
BrokenThread,
|
|
42
|
+
BrokenTyper,
|
|
43
|
+
OnceTracker,
|
|
44
|
+
PlainTracker,
|
|
45
|
+
clamp,
|
|
46
|
+
denum,
|
|
47
|
+
flatten,
|
|
48
|
+
hyphen_range,
|
|
49
|
+
limited_integer_ratio,
|
|
50
|
+
log,
|
|
51
|
+
)
|
|
52
|
+
from Broken.Externals.FFmpeg import BrokenFFmpeg
|
|
53
|
+
from Broken.Loaders import LoaderBytes, LoaderString
|
|
54
|
+
from Broken.Types import Hertz, Seconds, Unchanged
|
|
55
|
+
from ShaderFlow import SHADERFLOW
|
|
56
|
+
from ShaderFlow.Message import ShaderMessage
|
|
57
|
+
from ShaderFlow.Module import ShaderModule
|
|
58
|
+
from ShaderFlow.Modules.Camera import ShaderCamera
|
|
59
|
+
from ShaderFlow.Modules.Dynamics import DynamicNumber
|
|
60
|
+
from ShaderFlow.Modules.Frametimer import ShaderFrametimer
|
|
61
|
+
from ShaderFlow.Modules.Keyboard import ShaderKeyboard
|
|
62
|
+
from ShaderFlow.Shader import ShaderObject
|
|
63
|
+
from ShaderFlow.Variable import ShaderVariable
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class WindowBackend(BrokenEnum):
|
|
67
|
+
Headless = "headless"
|
|
68
|
+
GLFW = "glfw"
|
|
69
|
+
|
|
70
|
+
@define
|
|
71
|
+
class ShaderScene(ShaderModule):
|
|
72
|
+
__name__ = "Scene"
|
|
73
|
+
|
|
74
|
+
# # Base modules
|
|
75
|
+
|
|
76
|
+
modules: Deque[ShaderModule] = Factory(deque)
|
|
77
|
+
"""Deque of all Modules on the Scene, not a set for order preservation"""
|
|
78
|
+
|
|
79
|
+
# Scheduling
|
|
80
|
+
scheduler: BrokenScheduler = Factory(BrokenScheduler)
|
|
81
|
+
vsync: BrokenTask = None
|
|
82
|
+
|
|
83
|
+
# ShaderFlow modules
|
|
84
|
+
frametimer: ShaderFrametimer = None
|
|
85
|
+
keyboard: ShaderKeyboard = None
|
|
86
|
+
camera: ShaderCamera = None
|
|
87
|
+
ffmpeg: BrokenFFmpeg = None
|
|
88
|
+
|
|
89
|
+
# # Fractional SSAA
|
|
90
|
+
|
|
91
|
+
shader: ShaderObject = None
|
|
92
|
+
"""The main ShaderObject of the Scene, the visible content of the Window"""
|
|
93
|
+
|
|
94
|
+
_final: ShaderObject = None
|
|
95
|
+
"""Internal ShaderObject used for a Fractional Super-Sampling Anti-Aliasing (SSAA). This shader
|
|
96
|
+
samples the texture from the user's final self.shader, which is rendered at SSAA resolution"""
|
|
97
|
+
|
|
98
|
+
alpha: bool = False
|
|
99
|
+
"""Makes the final texture have an alpha channel, useful for transparent windows. Exporting
|
|
100
|
+
videos might fail, perhaps output a Chroma Key compatible video - add this to the shader:
|
|
101
|
+
- `fragColor.rgb = mix(vec3(0, 1, 0), fragColor.rgb, fragColor.a);`"""
|
|
102
|
+
|
|
103
|
+
quality: float = field(default=50, converter=lambda x: clamp(x, 0, 100))
|
|
104
|
+
"""Rendering Quality, if implemented - either on the GPU Shader or CPU Python side"""
|
|
105
|
+
|
|
106
|
+
typer: BrokenTyper = Factory(lambda: BrokenTyper(chain=True))
|
|
107
|
+
"""This Scene's BrokenTyper instance for the CLI. Commands are added by any module in the
|
|
108
|
+
`self.commands` method. The `self.main` is always added to it"""
|
|
109
|
+
|
|
110
|
+
def __post__(self):
|
|
111
|
+
self.typer.description = (self.typer.description or self.__class__.__doc__)
|
|
112
|
+
self.typer._panel = "🔥 Scene Commands"
|
|
113
|
+
self.typer.command(self.main, context=True)
|
|
114
|
+
self.build()
|
|
115
|
+
|
|
116
|
+
def cli(self, *args: List[Union[Any, str]]):
|
|
117
|
+
"""Interpret a list of arguments as actions, defined by the Scene's `self.commands` plus
|
|
118
|
+
the `main` method. Must not start with `sys.executable`, so send `sys.argv[1:]` or direct"""
|
|
119
|
+
self.typer(flatten(args))
|
|
120
|
+
|
|
121
|
+
__built__: OnceTracker = Factory(OnceTracker)
|
|
122
|
+
|
|
123
|
+
def build(self):
|
|
124
|
+
if self.__built__():
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# Init Imgui
|
|
128
|
+
imgui.create_context()
|
|
129
|
+
self.imguio = imgui.get_io()
|
|
130
|
+
self.imguio.font_global_scale = 1
|
|
131
|
+
self.imguio.fonts.add_font_from_file_ttf(
|
|
132
|
+
str(Broken.BROKEN.RESOURCES.FONTS/"DejaVuSans.ttf"),
|
|
133
|
+
16*self.imguio.font_global_scale,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Default modules
|
|
137
|
+
self.init_window()
|
|
138
|
+
self.frametimer = ShaderFrametimer(scene=self)
|
|
139
|
+
self.keyboard = ShaderKeyboard(scene=self)
|
|
140
|
+
self.camera = ShaderCamera(scene=self)
|
|
141
|
+
|
|
142
|
+
# Create the SSAA Workaround engines
|
|
143
|
+
self._final = ShaderObject(scene=self, name="iFinal")
|
|
144
|
+
self._final.texture.components = 3 + int(self.alpha)
|
|
145
|
+
self._final.texture.dtype = "f1"
|
|
146
|
+
self._final.texture.final = True
|
|
147
|
+
self._final.texture.track = True
|
|
148
|
+
self._final.fragment = (SHADERFLOW.RESOURCES.FRAGMENT/"Final.glsl")
|
|
149
|
+
self.shader = ShaderObject(scene=self, name="iScreen")
|
|
150
|
+
self.shader.texture.track = True
|
|
151
|
+
self.shader.texture.repeat(False)
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------------------------|
|
|
154
|
+
# Temporal
|
|
155
|
+
|
|
156
|
+
time: Seconds = field(default=0.0, converter=float)
|
|
157
|
+
"""Current time in seconds. Ideally, everything should depend on time, for flexibility"""
|
|
158
|
+
|
|
159
|
+
tempo: float = Factory(lambda: DynamicNumber(value=1, frequency=3))
|
|
160
|
+
"""Time scale factor, used for `dt`, which integrates to `time`"""
|
|
161
|
+
|
|
162
|
+
runtime: Seconds = field(default=10.0, converter=float)
|
|
163
|
+
"""The longest module duration; overriden by the user; or default length of 10s"""
|
|
164
|
+
|
|
165
|
+
fps: Hertz = field(default=60.0, converter=lambda x: max(float(x), 1.0))
|
|
166
|
+
"""Target frames per second rendering speed"""
|
|
167
|
+
|
|
168
|
+
dt: Seconds = field(default=0.0, converter=float)
|
|
169
|
+
"""Virtual delta time since last frame, time scaled by `tempo`. Use `self.rdt` for real delta"""
|
|
170
|
+
|
|
171
|
+
rdt: Seconds = field(default=0.0, converter=float)
|
|
172
|
+
"""Real life, physical delta time since last frame. Use `self.dt` for time scaled version"""
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def tau(self) -> float:
|
|
176
|
+
"""Normalized time value relative to runtime between 0 and 1"""
|
|
177
|
+
return (self.time / self.runtime)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def cycle(self) -> float:
|
|
181
|
+
"""A number from 0 to 2pi that ends on the runtime ('normalized angular time')"""
|
|
182
|
+
return (2 * math.pi * self.tau)
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def frametime(self) -> Seconds:
|
|
186
|
+
"""Ideal time between two frames. This value is coupled with `fps`"""
|
|
187
|
+
return (1 / self.fps)
|
|
188
|
+
|
|
189
|
+
@frametime.setter
|
|
190
|
+
def frametime(self, value: Seconds):
|
|
191
|
+
self.fps = (1 / value)
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def frame(self) -> int:
|
|
195
|
+
"""Current frame being rendered. This value is coupled with 'time' and 'fps'"""
|
|
196
|
+
return round(self.time * self.fps)
|
|
197
|
+
|
|
198
|
+
@frame.setter
|
|
199
|
+
def frame(self, value: int):
|
|
200
|
+
self.time = (value / self.fps)
|
|
201
|
+
|
|
202
|
+
# Total Duration
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def duration(self) -> Seconds:
|
|
206
|
+
"""Alias to self.runtime. Set both with `.set_duration()`"""
|
|
207
|
+
return self.runtime
|
|
208
|
+
|
|
209
|
+
def set_duration(self, override: Seconds=None, *, minimum: Seconds=10) -> Seconds:
|
|
210
|
+
"""Either force the runtime to be 'override' or find the longest module lower bounded"""
|
|
211
|
+
self.runtime = (override or minimum)
|
|
212
|
+
for module in (not bool(override)) * self.modules:
|
|
213
|
+
self.runtime = max(self.runtime, module.duration)
|
|
214
|
+
self.runtime /= self.tempo.value
|
|
215
|
+
return self.runtime
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def total_frames(self) -> int:
|
|
219
|
+
"""The total frames this scene should render when exporting, if 'runtime' isn't changed"""
|
|
220
|
+
return round(self.runtime * self.fps)
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------------------------|
|
|
223
|
+
# Window synchronized properties
|
|
224
|
+
|
|
225
|
+
# # Title
|
|
226
|
+
|
|
227
|
+
_title: str = "ShaderFlow"
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def title(self) -> str:
|
|
231
|
+
"""Realtime window 'title' property"""
|
|
232
|
+
return self._title
|
|
233
|
+
|
|
234
|
+
@title.setter
|
|
235
|
+
def title(self, value: str):
|
|
236
|
+
log.debug(f"{self.who} Changing Window Title to ({value})")
|
|
237
|
+
self.window.title = value
|
|
238
|
+
self._title = value
|
|
239
|
+
|
|
240
|
+
# # Resizable
|
|
241
|
+
|
|
242
|
+
_resizable: bool = True
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def resizable(self) -> bool:
|
|
246
|
+
"""Realtime window 'is resizable' property"""
|
|
247
|
+
return self._resizable
|
|
248
|
+
|
|
249
|
+
@resizable.setter
|
|
250
|
+
def resizable(self, value: bool):
|
|
251
|
+
log.debug(f"{self.who} Changing Window Resizable to ({value})")
|
|
252
|
+
self.window.resizable = value
|
|
253
|
+
self._resizable = value
|
|
254
|
+
|
|
255
|
+
# # Visible
|
|
256
|
+
|
|
257
|
+
_visible: bool = False
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def visible(self) -> bool:
|
|
261
|
+
"""Realtime window 'is visible' property"""
|
|
262
|
+
return self._visible
|
|
263
|
+
|
|
264
|
+
@visible.setter
|
|
265
|
+
def visible(self, value: bool):
|
|
266
|
+
log.debug(f"{self.who} Changing Window Visibility to ({value})")
|
|
267
|
+
self.window.visible = value
|
|
268
|
+
self._visible = value
|
|
269
|
+
|
|
270
|
+
# # Window Fullscreen
|
|
271
|
+
|
|
272
|
+
_fullscreen: bool = False
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def fullscreen(self) -> bool:
|
|
276
|
+
"""Window 'is fullscreen' property"""
|
|
277
|
+
return self._fullscreen
|
|
278
|
+
|
|
279
|
+
@fullscreen.setter
|
|
280
|
+
def fullscreen(self, value: bool):
|
|
281
|
+
log.debug(f"{self.who} Changing Window Fullscreen to ({value})")
|
|
282
|
+
self._fullscreen = value
|
|
283
|
+
try:
|
|
284
|
+
self.window.fullscreen = value
|
|
285
|
+
except AttributeError:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
# # Window Exclusive
|
|
289
|
+
|
|
290
|
+
_exclusive: bool = False
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def exclusive(self) -> bool:
|
|
294
|
+
"""Window 'mouse exclusivity' property: Is the mouse cursor be locked to the window"""
|
|
295
|
+
return self._exclusive
|
|
296
|
+
|
|
297
|
+
@exclusive.setter
|
|
298
|
+
def exclusive(self, value: bool):
|
|
299
|
+
log.debug(f"{self.who} Changing Window Exclusive to ({value})")
|
|
300
|
+
self.window.mouse_exclusivity = value
|
|
301
|
+
self._exclusive = value
|
|
302
|
+
|
|
303
|
+
# # Video modes and monitor
|
|
304
|
+
|
|
305
|
+
monitor: int = os.environ.get("MONITOR", 0)
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def glfw_monitor(self) -> Optional[glfw._GLFWmonitor]:
|
|
309
|
+
if (monitors := glfw.get_monitors()):
|
|
310
|
+
return monitors[self.monitor]
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def glfw_video_mode(self) -> Optional[Dict]:
|
|
314
|
+
if (monitor := self.glfw_monitor):
|
|
315
|
+
return glfw.get_video_mode(monitor)
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def monitor_framerate(self) -> float:
|
|
319
|
+
"""Note: Defaults to 60 if no monitor is found"""
|
|
320
|
+
if (mode := self.glfw_video_mode):
|
|
321
|
+
return mode.refresh_rate or 60.0
|
|
322
|
+
return 60.0
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def monitor_size(self) -> Optional[Tuple[int, int]]:
|
|
326
|
+
if self.exporting:
|
|
327
|
+
return None
|
|
328
|
+
if (mode := self.glfw_video_mode):
|
|
329
|
+
return (mode.size.width, mode.size.height)
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def monitor_width(self) -> Optional[int]:
|
|
333
|
+
if (resolution := self.monitor_size):
|
|
334
|
+
return resolution[0]
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def monitor_height(self) -> Optional[int]:
|
|
338
|
+
if (resolution := self.monitor_size):
|
|
339
|
+
return resolution[1]
|
|
340
|
+
|
|
341
|
+
# ---------------------------------------------------------------------------------------------|
|
|
342
|
+
# Resolution
|
|
343
|
+
|
|
344
|
+
# # Scale
|
|
345
|
+
|
|
346
|
+
_scale: float = field(default=1.0, converter=lambda x: max(0.01, x))
|
|
347
|
+
|
|
348
|
+
@property
|
|
349
|
+
def scale(self) -> float:
|
|
350
|
+
"""Resolution scale factor, the `self.width` and `self.height` are multiplied by this"""
|
|
351
|
+
return self._scale
|
|
352
|
+
|
|
353
|
+
@scale.setter
|
|
354
|
+
def scale(self, value: float):
|
|
355
|
+
log.debug(f"{self.who} Changing Resolution Scale to ({value})")
|
|
356
|
+
self.resize(scale=value)
|
|
357
|
+
|
|
358
|
+
# # Width
|
|
359
|
+
|
|
360
|
+
_width: int = field(default=1920, converter=lambda x: int(max(1, x)))
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def width(self) -> int:
|
|
364
|
+
"""Rendering width (horizontal size) of the Scene in pixels"""
|
|
365
|
+
return round(self._width * self._scale)
|
|
366
|
+
|
|
367
|
+
@width.setter
|
|
368
|
+
def width(self, value: int):
|
|
369
|
+
self.resize(width=value)
|
|
370
|
+
|
|
371
|
+
# # Height
|
|
372
|
+
|
|
373
|
+
_height: int = field(default=1080, converter=lambda x: int(max(1, x)))
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def height(self) -> int:
|
|
377
|
+
"""Rendering height (vertical size) of the Scene in pixels"""
|
|
378
|
+
return round(self._height * self._scale)
|
|
379
|
+
|
|
380
|
+
@height.setter
|
|
381
|
+
def height(self, value: int):
|
|
382
|
+
self.resize(height=value)
|
|
383
|
+
|
|
384
|
+
# # SSAA
|
|
385
|
+
|
|
386
|
+
_ssaa: float = field(default=1.0, converter=lambda x: max(0.01, x))
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def ssaa(self) -> float:
|
|
390
|
+
"""Fractional Super Sampling Anti-Aliasing (SSAA) factor. Improves the image quality (>1) by
|
|
391
|
+
rendering at a higher resolution and then downsampling, resulting in smoother edges at a
|
|
392
|
+
significant GPU computational cost of O(N^2). Values lower than 1 (yield worse quality, but)
|
|
393
|
+
are useful when the GPU can't keep up: when the resolution is too high or FPS is too low"""
|
|
394
|
+
return self._ssaa
|
|
395
|
+
|
|
396
|
+
@ssaa.setter
|
|
397
|
+
def ssaa(self, value: float):
|
|
398
|
+
log.debug(f"{self.who} Changing Fractional SSAA to {value}")
|
|
399
|
+
self._ssaa = value
|
|
400
|
+
self.relay(ShaderMessage.Shader.RecreateTextures)
|
|
401
|
+
|
|
402
|
+
# # Resolution (With, Height)
|
|
403
|
+
|
|
404
|
+
@property
|
|
405
|
+
def resolution(self) -> Tuple[int, int]:
|
|
406
|
+
"""The resolution the Scene is rendering in pixels"""
|
|
407
|
+
return (self.width, self.height)
|
|
408
|
+
|
|
409
|
+
@resolution.setter
|
|
410
|
+
def resolution(self, value: Tuple[int, int]):
|
|
411
|
+
self.resize(*value)
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def render_resolution(self) -> Tuple[int, int]:
|
|
415
|
+
"""Internal 'true' rendering resolution for SSAA. Same as `self.resolution*self.ssaa`"""
|
|
416
|
+
return (round(self.width*self.ssaa), round(self.height*self.ssaa))
|
|
417
|
+
|
|
418
|
+
# # Aspect Ratio
|
|
419
|
+
|
|
420
|
+
_aspect_ratio: float = None
|
|
421
|
+
|
|
422
|
+
@property
|
|
423
|
+
def aspect_ratio(self) -> float:
|
|
424
|
+
"""Either the forced `self._aspect_ratio` or dynamic from `self.width/self.height`. When set
|
|
425
|
+
and resizing, the logic of `BrokenResolution.fit` is applied to enforce ratios"""
|
|
426
|
+
return self._aspect_ratio or (self._width/self._height)
|
|
427
|
+
|
|
428
|
+
@aspect_ratio.setter
|
|
429
|
+
def aspect_ratio(self, value: Union[float, str]):
|
|
430
|
+
log.debug(f"{self.who} Changing Aspect Ratio to {value}")
|
|
431
|
+
|
|
432
|
+
# The aspect ratio can be sent as a fraction or "none", "false"
|
|
433
|
+
if isinstance(value, str):
|
|
434
|
+
value = eval(value.replace(":", "/").capitalize())
|
|
435
|
+
|
|
436
|
+
# Optimization: Only change if different
|
|
437
|
+
if (self._aspect_ratio == value):
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
self._aspect_ratio = value
|
|
441
|
+
|
|
442
|
+
if (self.backend == WindowBackend.GLFW):
|
|
443
|
+
num, den = limited_integer_ratio(self._aspect_ratio, limit=2**20) or (glfw.DONT_CARE, glfw.DONT_CARE)
|
|
444
|
+
glfw.set_window_aspect_ratio(self.window._window, num, den)
|
|
445
|
+
|
|
446
|
+
def resize(self,
|
|
447
|
+
width: Union[int, float]=Unchanged,
|
|
448
|
+
height: Union[int, float]=Unchanged,
|
|
449
|
+
*,
|
|
450
|
+
aspect_ratio: Union[Unchanged, float, str]=Unchanged,
|
|
451
|
+
scale: float=Unchanged
|
|
452
|
+
) -> Tuple[int, int]:
|
|
453
|
+
"""
|
|
454
|
+
Resize the true final rendering resolution of the Scene. Rounded to nearest multiple of 2,
|
|
455
|
+
so FFmpeg is happy, and limited by the monitor resolution if realtime
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
width: New width of the Scene, None to not change
|
|
459
|
+
height: New height of the Scene, None to not change
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
Self: Fluent interface
|
|
463
|
+
"""
|
|
464
|
+
|
|
465
|
+
# Maybe update auxiliary properties
|
|
466
|
+
self.aspect_ratio = (aspect_ratio or self._aspect_ratio)
|
|
467
|
+
self._scale = (scale or self._scale)
|
|
468
|
+
|
|
469
|
+
# The parameters aren't trivial. The idea is to fit resolution from the scale-less components,
|
|
470
|
+
# so scaling isn't carried over, then to apply scaling (self.resolution)
|
|
471
|
+
resolution = BrokenResolution.fit(
|
|
472
|
+
old=(self._width, self._height),
|
|
473
|
+
new=(width, height),
|
|
474
|
+
max=(self.monitor_size),
|
|
475
|
+
ar=self._aspect_ratio
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# Optimization: Only resize when resolution changes
|
|
479
|
+
if (resolution != (self.width, self.height)):
|
|
480
|
+
self._width, self._height = resolution
|
|
481
|
+
self.window.size = self.resolution
|
|
482
|
+
self.relay(ShaderMessage.Shader.RecreateTextures)
|
|
483
|
+
log.info(f"{self.who} Resized Window to {self.resolution}")
|
|
484
|
+
|
|
485
|
+
return self.resolution
|
|
486
|
+
|
|
487
|
+
# ---------------------------------------------------------------------------------------------|
|
|
488
|
+
# Window, OpenGL, Backend
|
|
489
|
+
|
|
490
|
+
backend: WindowBackend = WindowBackend.get(os.environ.get("WINDOW_BACKEND", WindowBackend.GLFW))
|
|
491
|
+
"""The ModernGL Window Backend. **Cannot be changed after creation**. Can also be set with the
|
|
492
|
+
environment variable `WINDOW_BACKEND=<backend>`, where `backend = {glfw, headless}`"""
|
|
493
|
+
|
|
494
|
+
opengl: moderngl.Context = None
|
|
495
|
+
"""ModernGL Context of this Scene. The thread accessing this MUST own or ENTER its context for
|
|
496
|
+
creating, changing, deleting objects; more often than not, it's the Main thread"""
|
|
497
|
+
|
|
498
|
+
window: ModernglWindow = None
|
|
499
|
+
"""ModernGL Window instance at `site-packages/moderngl_window.context.<self.backend>.Window`"""
|
|
500
|
+
|
|
501
|
+
imgui: ModernglImgui = None
|
|
502
|
+
"""ModernGL Imgui integration class bound to the Window"""
|
|
503
|
+
|
|
504
|
+
imguio: Any = None
|
|
505
|
+
"""Imgui IO object"""
|
|
506
|
+
|
|
507
|
+
def init_window(self) -> None:
|
|
508
|
+
"""Create the window and the OpenGL context"""
|
|
509
|
+
if self.window:
|
|
510
|
+
raise RuntimeError("Window backend cannot be changed after creation")
|
|
511
|
+
|
|
512
|
+
# Use EGL for creating a OpenGL context, allows true headless with GPU acceleration
|
|
513
|
+
# https://forums.developer.nvidia.com/t/81412 - Comments 2 and 6
|
|
514
|
+
backend = "egl" if (os.environ.get("WINDOW_EGL", "1") == "1") else None
|
|
515
|
+
|
|
516
|
+
# Dynamically import the ModernGL Window Backend and instantiate it. Vsync is on our side 😉
|
|
517
|
+
module = f"moderngl_window.context.{denum(self.backend).lower()}"
|
|
518
|
+
self.window = importlib.import_module(module).Window(
|
|
519
|
+
size=self.resolution,
|
|
520
|
+
title=self.title,
|
|
521
|
+
resizable=self.resizable,
|
|
522
|
+
visible=self.visible,
|
|
523
|
+
fullscreen=self.fullscreen,
|
|
524
|
+
vsync=False,
|
|
525
|
+
backend=backend
|
|
526
|
+
)
|
|
527
|
+
ShaderKeyboard.set_keymap(self.window.keys)
|
|
528
|
+
self.imgui = ModernglImgui(self.window)
|
|
529
|
+
self.opengl = self.window.ctx
|
|
530
|
+
|
|
531
|
+
# Bind window events to relay
|
|
532
|
+
self.window.resize_func = self.__window_resize__
|
|
533
|
+
self.window.close_func = self.__window_close__
|
|
534
|
+
self.window.iconify_func = self.__window_iconify__
|
|
535
|
+
self.window.key_event_func = self.__window_key_event__
|
|
536
|
+
self.window.mouse_position_event_func = self.__window_mouse_position_event__
|
|
537
|
+
self.window.mouse_press_event_func = self.__window_mouse_press_event__
|
|
538
|
+
self.window.mouse_release_event_func = self.__window_mouse_release_event__
|
|
539
|
+
self.window.mouse_drag_event_func = self.__window_mouse_drag_event__
|
|
540
|
+
self.window.mouse_scroll_event_func = self.__window_mouse_scroll_event__
|
|
541
|
+
self.window.unicode_char_entered_func = self.__window_unicode_char_entered__
|
|
542
|
+
self.window.files_dropped_event_func = self.__window_files_dropped_event__
|
|
543
|
+
|
|
544
|
+
if (self.backend == WindowBackend.GLFW):
|
|
545
|
+
BrokenThread.new(target=self.window.set_icon, icon_path=Broken.PROJECT.RESOURCES.ICON, daemon=True)
|
|
546
|
+
glfw.set_cursor_enter_callback(self.window._window, lambda _, enter: self.__window_mouse_enter_event__(inside=enter))
|
|
547
|
+
glfw.set_drop_callback(self.window._window, self.__window_files_dropped_event__)
|
|
548
|
+
ShaderKeyboard.Keys.LEFT_SHIFT = glfw.KEY_LEFT_SHIFT
|
|
549
|
+
ShaderKeyboard.Keys.LEFT_CTRL = glfw.KEY_LEFT_CONTROL
|
|
550
|
+
ShaderKeyboard.Keys.LEFT_ALT = glfw.KEY_LEFT_ALT
|
|
551
|
+
|
|
552
|
+
log.debug(f"{self.who} Finished Window creation")
|
|
553
|
+
|
|
554
|
+
def read_screen(self) -> bytes:
|
|
555
|
+
"""Take a screenshot of the screen and return raw bytes. Length `width*height*components`"""
|
|
556
|
+
return self.window.fbo.read(viewport=(0, 0, self.width, self.height))
|
|
557
|
+
|
|
558
|
+
# ---------------------------------------------------------------------------------------------|
|
|
559
|
+
# User actions
|
|
560
|
+
|
|
561
|
+
@property
|
|
562
|
+
def directory(self) -> Path:
|
|
563
|
+
"""Path of the current Scene file Python script. This works by searching up the call stack
|
|
564
|
+
for the first context whose filename isn't the local __file__ (of ShaderFlow.Scene)"""
|
|
565
|
+
# Idea: Maybe `type(self).mro()[0]` could help
|
|
566
|
+
for frame in inspect.stack():
|
|
567
|
+
if (frame.filename != __file__):
|
|
568
|
+
return Path(frame.filename).parent
|
|
569
|
+
|
|
570
|
+
def read_file(self, file: Path, bytes: bool=False) -> Union[str, bytes]:
|
|
571
|
+
"""
|
|
572
|
+
Read a file relative to the current Scene Python script
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
file: File to read, relative to the current Scene script directory
|
|
576
|
+
bytes: Whether to read the file as bytes, defaults to text
|
|
577
|
+
|
|
578
|
+
Returns:
|
|
579
|
+
File contents as text or bytes
|
|
580
|
+
"""
|
|
581
|
+
file = (self.directory/file)
|
|
582
|
+
log.info(f"{self.who} Reading file ({file})")
|
|
583
|
+
return LoaderBytes(file) if bytes else LoaderString(file)
|
|
584
|
+
|
|
585
|
+
# ---------------------------------------------------------------------------------------------|
|
|
586
|
+
# Main event loop
|
|
587
|
+
|
|
588
|
+
quit: PlainTracker = Factory(lambda: PlainTracker(False))
|
|
589
|
+
"""Should the scene end the main event loop? Use as `if scene.quit():`"""
|
|
590
|
+
|
|
591
|
+
def next(self, dt: float) -> Self:
|
|
592
|
+
"""Integrate time, update all modules and render the next frame"""
|
|
593
|
+
|
|
594
|
+
# Fixme: Windows: https://github.com/glfw/glfw/pull/1426
|
|
595
|
+
# Immediately swap the buffer with previous frame for vsync
|
|
596
|
+
if (self.backend != WindowBackend.Headless):
|
|
597
|
+
self.window.swap_buffers()
|
|
598
|
+
|
|
599
|
+
# Temporal logic
|
|
600
|
+
dt = min(dt, 1)
|
|
601
|
+
self.tempo.next(dt=abs(dt))
|
|
602
|
+
self.rdt = dt
|
|
603
|
+
self.dt = dt * self.tempo
|
|
604
|
+
self.time += self.dt
|
|
605
|
+
self.vsync.fps = self.fps
|
|
606
|
+
|
|
607
|
+
# Note: Updates in reverse order of addition (child -> parent)
|
|
608
|
+
# Note: Update non-engine first, as the pipeline might change
|
|
609
|
+
for module in self.modules:
|
|
610
|
+
if not isinstance(module, ShaderObject):
|
|
611
|
+
module.update()
|
|
612
|
+
for module in reversed(self.modules):
|
|
613
|
+
if isinstance(module, ShaderObject):
|
|
614
|
+
module.update()
|
|
615
|
+
|
|
616
|
+
self._render_ui()
|
|
617
|
+
return self
|
|
618
|
+
|
|
619
|
+
exporting: bool = True
|
|
620
|
+
"""Is this Scene exporting to a video file?"""
|
|
621
|
+
|
|
622
|
+
rendering: bool = False
|
|
623
|
+
"""Either Exporting, Rendering or Benchmarking. 'Not Realtime' mode"""
|
|
624
|
+
|
|
625
|
+
realtime: bool = False
|
|
626
|
+
"""Running with a window and user interaction"""
|
|
627
|
+
|
|
628
|
+
headless: bool = False
|
|
629
|
+
"""Running Headlessly, without a window and user interaction"""
|
|
630
|
+
|
|
631
|
+
benchmark: bool = False
|
|
632
|
+
"""Stress test the rendering speed of the Scene"""
|
|
633
|
+
|
|
634
|
+
repeat: int = field(default=1, converter=int)
|
|
635
|
+
"""Number of times to loop the exported video. One '1' is no repeat, two '2' doubles the length.
|
|
636
|
+
Ideally have seamless transitions on the shader based on self.tau and/or/no audio input"""
|
|
637
|
+
|
|
638
|
+
# Batch exporting
|
|
639
|
+
|
|
640
|
+
export_batch: Iterable[int] = field(factory=lambda: [0], converter=lambda x: list(x) or [0])
|
|
641
|
+
"""Batch indices iterable to export"""
|
|
642
|
+
|
|
643
|
+
export_index: int = 0
|
|
644
|
+
"""Current Batch exporting video index"""
|
|
645
|
+
|
|
646
|
+
export_format: str = field(default="mp4", converter=lambda x: str(denum(x)))
|
|
647
|
+
"""The last (or only) video export format (extension) to use"""
|
|
648
|
+
|
|
649
|
+
export_base: Path = field(default=Broken.PROJECT.DIRECTORIES.DATA, converter=lambda x: Path(x))
|
|
650
|
+
"""The last (or only) video export base directory. Videos should render to ($base/$name) if $name
|
|
651
|
+
is plain, that is, the path isn't absolute"""
|
|
652
|
+
|
|
653
|
+
@abstractmethod
|
|
654
|
+
def export_name(self, path: Path) -> Path:
|
|
655
|
+
"""Change the video file name being exported based on the current batch index. By default,
|
|
656
|
+
the name is unchanged in single export, else the stem is appended with the batch index"""
|
|
657
|
+
if (len(self.export_batch) > 1):
|
|
658
|
+
return path.with_stem(f"{path.stem}_{self.export_index}")
|
|
659
|
+
return path
|
|
660
|
+
|
|
661
|
+
def main(self,
|
|
662
|
+
width: Annotated[int, Option("--width", "-w", help="[bold][red](🔴 Basic )[/red][/bold] Width of the rendering resolution [medium_purple3](None to keep or find by Aspect Ratio)[/medium_purple3] [dim](1920 on init)[/dim]")]=None,
|
|
663
|
+
height: Annotated[int, Option("--height", "-h", help="[bold][red](🔴 Basic )[/red][/bold] Height of the rendering resolution [medium_purple3](None to keep or find by Aspect Ratio)[/medium_purple3] [dim](1080 on init)[/dim]")]=None,
|
|
664
|
+
scale: Annotated[float, Option("--scale", "-x", help="[bold][red](🔴 Basic )[/red][/bold] Post-multiply width and height by a scale factor [medium_purple3](None to keep)[/medium_purple3] [dim](1.0 on init)[dim]")]=None,
|
|
665
|
+
aspect: Annotated[str, Option("--ar", "-R", help="[bold][red](🔴 Basic )[/red][/bold] Force resolution aspect ratio [green](Examples: '16:9', '16/9', '1.777')[/green] [medium_purple3](None for dynamic)[/medium_purple3]")]=None,
|
|
666
|
+
fps: Annotated[float, Option("--fps", "-f", help="[bold][red](🔴 Basic )[/red][/bold] Target frames per second [medium_purple3](Defaults to the monitor framerate on realtime else 60)[/medium_purple3]")]=None,
|
|
667
|
+
fullscreen: Annotated[bool, Option("--fullscreen", help="[bold][red](🔴 Basic )[/red][/bold] Start the real time window in fullscreen mode [medium_purple3](Toggle with F11)[/medium_purple3]")]=False,
|
|
668
|
+
maximize: Annotated[bool, Option("--maximize", "-M", help="[bold][red](🔴 Basic )[/red][/bold] Start the real time window in maximized mode")]=False,
|
|
669
|
+
quality: Annotated[float, Option("--quality", "-q", help="[bold][yellow](🟡 Quality)[/yellow][/bold] Shader quality level [green](0-100%)[/green] [yellow](If supported by the shader)[/yellow] [medium_purple3](None to keep, default 50%)[/medium_purple3]")]=None,
|
|
670
|
+
ssaa: Annotated[float, Option("--ssaa", "-s", help="[bold][yellow](🟡 Quality)[/yellow][/bold] Fractional super sampling anti aliasing factor [yellow](O(N^2) GPU cost)[/yellow] [medium_purple3](None to keep, default 1.0)[/medium_purple3]")]=None,
|
|
671
|
+
render: Annotated[bool, Option("--render", "-r", help="[green](🟢 Export )[/green] Export the Scene to a video file [medium_purple3](defined on --output, and implicit if so)[/medium_purple3]")]=False,
|
|
672
|
+
output: Annotated[str, Option("--output", "-o", help="[green](🟢 Export )[/green] Output file name [green]('Absolute', 'Relative path', 'Plain Name')[/green] [dim]($base/$(plain_name or $scene-$date))[/dim]")]=None,
|
|
673
|
+
time: Annotated[float, Option("--time", "-t", help="[green](🟢 Export )[/green] The duration of exported videos [dim](Loop duration)[/dim] [medium_purple3](defaults to 10 or longest module's duration)[/medium_purple3]")]=None,
|
|
674
|
+
tempo: Annotated[float, Option("--tempo", "-T", help="[green](🟢 Export )[/green] Set the time speed factor of the Scene [yellow](Final duration is stretched by [italic]1/tempo[/italic])[/yellow] [dim](1 on init)[/dim]")]=None,
|
|
675
|
+
repeat: Annotated[int, Option("--repeat", help="[green](🟢 Export )[/green] Number of exported videos loop copies [yellow](Final duration is stretched by [italic]repeat[/italic])[/yellow] [dim](1 on init)[/dim]")]=None,
|
|
676
|
+
format: Annotated[str, Option("--format", "-F", help="[green](🟢 Export )[/green] Output video container [green]('mp4', 'mkv', 'webm', 'avi, '...')[/green] [yellow](Overrides --output one)[/yellow]")]="mp4",
|
|
677
|
+
base: Annotated[Path, Option("--base", "-D", help="[green](🟢 Export )[/green] Output file base directory")]=Broken.PROJECT.DIRECTORIES.DATA,
|
|
678
|
+
vcodec: Annotated[str, Option("--vcodec", "-c", help="[green](🟢 Export )[/green] Video codec [green]('h264', 'h264-nvenc', 'h265, 'hevc-nvenc', 'vp9', 'av1-{aom,svt,nvenc,rav1e}')[/green]")]="h264",
|
|
679
|
+
acodec: Annotated[str, Option("--acodec", "-a", help="[green](🟢 Export )[/green] Audio codec [green]('aac', 'mp3', 'flac', 'wav', 'opus', 'ogg', 'copy', 'none', 'empty')[/green]")]="copy",
|
|
680
|
+
batch: Annotated[str, Option("--batch", "-b", help="[bold][blue](🔵 Special)[/blue][/bold] [dim][WIP] Hyphenated indices range to export multiple videos, if implemented. (1,5-7,10)[/dim]")]="0",
|
|
681
|
+
benchmark: Annotated[bool, Option("--benchmark", "-B", help="[bold][blue](🔵 Special)[/blue][/bold] Benchmark the Scene's speed on raw rendering [medium_purple3](Use SKIP_GPU=1 for CPU only benchmark)[/medium_purple3]")]=False,
|
|
682
|
+
raw: Annotated[bool, Option("--raw", help="[bold][blue](🔵 Special)[/blue][/bold] Send raw OpenGL frames before GPU SSAA to FFmpeg [medium_purple3](Enabled if SSAA < 1)[/medium_purple3] [dim](CPU Downsampling)[/dim]")]=False,
|
|
683
|
+
open: Annotated[bool, Option("--open", help="[bold][blue](🔵 Special)[/blue][/bold] Open the directory of video exports after all rendering finishes")]=False,
|
|
684
|
+
) -> Optional[List[Path]]:
|
|
685
|
+
"""
|
|
686
|
+
Main event loop of this ShaderFlow Scene. Start a realtime window, exports to video, stress test speeds
|
|
687
|
+
|
|
688
|
+
• Note: For advanced video or audio codec configuration, modify it on 'self.ffmpeg' maybe inheriting this base class
|
|
689
|
+
"""
|
|
690
|
+
outputs: List[Path] = []
|
|
691
|
+
|
|
692
|
+
from arrow import now as arrow_now
|
|
693
|
+
export_started = arrow_now().format("YYYY-MM-DD HH-mm-ss")
|
|
694
|
+
|
|
695
|
+
# Maybe update indices of exporting videos
|
|
696
|
+
self.export_batch = hyphen_range(batch) or self.export_batch
|
|
697
|
+
self.export_format = format
|
|
698
|
+
self.export_base = base
|
|
699
|
+
|
|
700
|
+
for index in self.export_batch:
|
|
701
|
+
self.export_index = index
|
|
702
|
+
self.exporting = (render or bool(output))
|
|
703
|
+
self.rendering = (self.exporting or benchmark)
|
|
704
|
+
self.realtime = (not self.rendering)
|
|
705
|
+
self.benchmark = benchmark
|
|
706
|
+
self.headless = (self.rendering)
|
|
707
|
+
self.fps = (fps or self.monitor_framerate)
|
|
708
|
+
self.title = f"ShaderFlow | {self.__name__}"
|
|
709
|
+
self.fullscreen = fullscreen
|
|
710
|
+
self.quality = quality or self.quality
|
|
711
|
+
self.repeat = repeat or self.repeat
|
|
712
|
+
self.ssaa = ssaa or self.ssaa
|
|
713
|
+
self.time = 0
|
|
714
|
+
self.tempo.set(tempo or self.tempo.value)
|
|
715
|
+
|
|
716
|
+
for module in self.modules:
|
|
717
|
+
module.setup()
|
|
718
|
+
|
|
719
|
+
self.relay(ShaderMessage.Shader.Compile)
|
|
720
|
+
self.set_duration(time)
|
|
721
|
+
|
|
722
|
+
# Maybe keep or force aspect ratio, and find best resolution
|
|
723
|
+
width, height = self.resize(width=width, height=height, scale=scale, aspect_ratio=aspect)
|
|
724
|
+
|
|
725
|
+
# Optimization: Save bandwidth by piping native frames on ssaa < 1
|
|
726
|
+
if self.rendering and (raw or self.ssaa < 1):
|
|
727
|
+
self.resolution = self.render_resolution
|
|
728
|
+
self.ssaa = 1
|
|
729
|
+
|
|
730
|
+
# Configure FFmpeg and Popen it
|
|
731
|
+
if (self.rendering):
|
|
732
|
+
|
|
733
|
+
# Get video output path - if not absolute, save to data directory
|
|
734
|
+
export = Path(output or f"({export_started}) {self.__name__}")
|
|
735
|
+
export = export if export.is_absolute() else (self.export_base/export)
|
|
736
|
+
export = export.with_suffix(export.suffix or f".{self.export_format}")
|
|
737
|
+
export = self.export_name(export)
|
|
738
|
+
|
|
739
|
+
self.ffmpeg = (BrokenFFmpeg(time=self.runtime).quiet()
|
|
740
|
+
.pipe_input(pixel_format=("rgba" if self.alpha else "rgb24"),
|
|
741
|
+
width=self.width, height=self.height, framerate=self.fps)
|
|
742
|
+
.scale(width=width, height=height)
|
|
743
|
+
.output(path=export)
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# Apply default good codec options on the video
|
|
747
|
+
self.ffmpeg.apply_vcodec_str(vcodec)
|
|
748
|
+
self.ffmpeg.apply_acodec_str(acodec)
|
|
749
|
+
|
|
750
|
+
for module in self.modules:
|
|
751
|
+
if module is self: continue
|
|
752
|
+
module.ffmpeg(self.ffmpeg)
|
|
753
|
+
|
|
754
|
+
if self.exporting:
|
|
755
|
+
buffer = self.opengl.buffer(reserve=self._final.texture.size_t)
|
|
756
|
+
self.ffmpeg = self.ffmpeg.popen(stdin=PIPE, wrapper=True)
|
|
757
|
+
|
|
758
|
+
# Status tracker
|
|
759
|
+
status = DotMap(
|
|
760
|
+
start=perf_counter(),
|
|
761
|
+
bar=tqdm.tqdm(
|
|
762
|
+
total=self.total_frames,
|
|
763
|
+
desc=f"Scene #{self.export_index} ({type(self).__name__}) → Video",
|
|
764
|
+
dynamic_ncols=True,
|
|
765
|
+
colour="#43BFEF",
|
|
766
|
+
leave=False,
|
|
767
|
+
unit=" frames",
|
|
768
|
+
mininterval=1/30,
|
|
769
|
+
maxinterval=0.1,
|
|
770
|
+
smoothing=0.1,
|
|
771
|
+
)
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
# Some scenes might take a while to setup
|
|
775
|
+
self.visible = not self.headless
|
|
776
|
+
|
|
777
|
+
if (self.backend == WindowBackend.GLFW and maximize):
|
|
778
|
+
glfw.maximize_window(self.window._window)
|
|
779
|
+
|
|
780
|
+
# Add self.next to the event loop
|
|
781
|
+
self.vsync = self.scheduler.new(
|
|
782
|
+
task=self.next,
|
|
783
|
+
frequency=self.fps,
|
|
784
|
+
freewheel=self.rendering,
|
|
785
|
+
precise=True,
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
# Main rendering loop
|
|
789
|
+
while (self.rendering) or (not self.quit()):
|
|
790
|
+
task = self.scheduler.next()
|
|
791
|
+
|
|
792
|
+
# Only continue if exporting
|
|
793
|
+
if (task.output is not self):
|
|
794
|
+
continue
|
|
795
|
+
if self.realtime:
|
|
796
|
+
continue
|
|
797
|
+
status.bar.update(1)
|
|
798
|
+
|
|
799
|
+
# Write a new frame to FFmpeg
|
|
800
|
+
if self.exporting:
|
|
801
|
+
if (_STANDARD_METHOD := False):
|
|
802
|
+
frame = self._final.texture.fbo().read()
|
|
803
|
+
self.ffmpeg.stdin.write(frame)
|
|
804
|
+
elif (_BUFFER_PROXY := True):
|
|
805
|
+
self._final.texture.fbo().read_into(buffer)
|
|
806
|
+
self.ffmpeg.stdin.write(buffer.read())
|
|
807
|
+
elif (_MGL_FORK_METHOD := False):
|
|
808
|
+
self._final.texture.fbo().read_into(buffer)
|
|
809
|
+
buffer.read_into(self.ffmpeg.stdin)
|
|
810
|
+
else:
|
|
811
|
+
raise RuntimeError("No read method selected")
|
|
812
|
+
|
|
813
|
+
# Finish exporting condition
|
|
814
|
+
if (status.bar.n < self.total_frames):
|
|
815
|
+
continue
|
|
816
|
+
status.bar.close()
|
|
817
|
+
|
|
818
|
+
if self.exporting:
|
|
819
|
+
log.info("Waiting for FFmpeg process to finish (Queue writes, codecs lookahead, etc)")
|
|
820
|
+
self.ffmpeg.stdin.close()
|
|
821
|
+
outputs.append(export)
|
|
822
|
+
|
|
823
|
+
# Repeat the video N times
|
|
824
|
+
if (self.repeat > 1):
|
|
825
|
+
log.info(f"Repeating video ({self.repeat-1} times)")
|
|
826
|
+
export.rename(temporary := export.with_stem(f"{export.stem}-repeat"))
|
|
827
|
+
(BrokenFFmpeg(stream_loop=(self.repeat-1)).quiet().copy_audio().copy_video()
|
|
828
|
+
.input(temporary).output(export, pixel_format=None).run())
|
|
829
|
+
temporary.unlink()
|
|
830
|
+
|
|
831
|
+
# Log stats
|
|
832
|
+
status.took = (perf_counter() - status.start)
|
|
833
|
+
log.info(f"Finished rendering ({export})", echo=not self.benchmark)
|
|
834
|
+
log.info((
|
|
835
|
+
f"• Stats: "
|
|
836
|
+
f"(Took {status.took:.2f} s) at "
|
|
837
|
+
f"({self.frame/status.took:.2f} FPS | "
|
|
838
|
+
f"{self.runtime/status.took:.2f} x Realtime) with "
|
|
839
|
+
f"({status.bar.n} Total Frames)"
|
|
840
|
+
))
|
|
841
|
+
|
|
842
|
+
if self.benchmark:
|
|
843
|
+
return None
|
|
844
|
+
break
|
|
845
|
+
|
|
846
|
+
BrokenPath.open_in_file_explorer(outputs[0].parent) if open else None
|
|
847
|
+
return outputs
|
|
848
|
+
|
|
849
|
+
# ---------------------------------------------------------------------------------------------|
|
|
850
|
+
# Module
|
|
851
|
+
|
|
852
|
+
def handle(self, message: ShaderMessage) -> None:
|
|
853
|
+
|
|
854
|
+
if isinstance(message, ShaderMessage.Window.Close):
|
|
855
|
+
log.info(f"{self.who} Received Window Close Event")
|
|
856
|
+
self.quit(True)
|
|
857
|
+
|
|
858
|
+
elif isinstance(message, ShaderMessage.Keyboard.KeyDown):
|
|
859
|
+
if message.key == ShaderKeyboard.Keys.O:
|
|
860
|
+
log.info(f"{self.who} (O ) Resetting the Scene")
|
|
861
|
+
for module in self.modules:
|
|
862
|
+
module.setup()
|
|
863
|
+
self.time = 0
|
|
864
|
+
|
|
865
|
+
elif message.key == ShaderKeyboard.Keys.R:
|
|
866
|
+
log.info(f"{self.who} (R ) Reloading Shaders")
|
|
867
|
+
for module in self.modules:
|
|
868
|
+
if isinstance(module, ShaderObject):
|
|
869
|
+
module.compile()
|
|
870
|
+
|
|
871
|
+
elif message.key == ShaderKeyboard.Keys.TAB:
|
|
872
|
+
log.info(f"{self.who} (TAB) Toggling Menu")
|
|
873
|
+
self.render_ui = not self.render_ui
|
|
874
|
+
|
|
875
|
+
elif message.key == ShaderKeyboard.Keys.F1:
|
|
876
|
+
log.info(f"{self.who} (F1 ) Toggling Exclusive Mode")
|
|
877
|
+
self.exclusive = not self.exclusive
|
|
878
|
+
|
|
879
|
+
elif message.key == ShaderKeyboard.Keys.F2:
|
|
880
|
+
import arrow
|
|
881
|
+
time = arrow.now().format("YYYY-MM-DD_HH-mm-ss")
|
|
882
|
+
image = PIL.Image.frombytes("RGB", self.resolution, self.read_screen())
|
|
883
|
+
image = image.transpose(PIL.Image.FLIP_TOP_BOTTOM)
|
|
884
|
+
path = Broken.PROJECT.DIRECTORIES.SCREENSHOTS/f"({time}) {self.__name__}.png"
|
|
885
|
+
log.minor(f"{self.who} (F2 ) Saving Screenshot to ({path})")
|
|
886
|
+
BrokenThread.new(target=image.save, fp=path)
|
|
887
|
+
|
|
888
|
+
elif message.key == ShaderKeyboard.Keys.F11:
|
|
889
|
+
log.info(f"{self.who} (F11) Toggling Fullscreen")
|
|
890
|
+
self.fullscreen = not self.fullscreen
|
|
891
|
+
|
|
892
|
+
elif isinstance(message, (ShaderMessage.Mouse.Drag, ShaderMessage.Mouse.Position)):
|
|
893
|
+
self.mouse_gluv = (message.u, message.v)
|
|
894
|
+
|
|
895
|
+
def pipeline(self) -> Iterable[ShaderVariable]:
|
|
896
|
+
yield ShaderVariable("uniform", "float", "iTime", self.time)
|
|
897
|
+
yield ShaderVariable("uniform", "float", "iDuration", self.duration)
|
|
898
|
+
yield ShaderVariable("uniform", "float", "iDeltaTime", self.dt)
|
|
899
|
+
yield ShaderVariable("uniform", "vec2", "iResolution", self.resolution)
|
|
900
|
+
yield ShaderVariable("uniform", "float", "iQuality", self.quality/100)
|
|
901
|
+
yield ShaderVariable("uniform", "float", "iSSAA", self.ssaa)
|
|
902
|
+
yield ShaderVariable("uniform", "float", "iFrameRate", self.fps)
|
|
903
|
+
yield ShaderVariable("uniform", "int", "iFrame", self.frame)
|
|
904
|
+
yield ShaderVariable("uniform", "bool", "iRealtime", self.realtime)
|
|
905
|
+
yield ShaderVariable("uniform", "vec2", "iMouse", self.mouse_gluv)
|
|
906
|
+
yield ShaderVariable("uniform", "bool", "iMouseInside", self.mouse_inside)
|
|
907
|
+
for i in range(1, 6):
|
|
908
|
+
yield ShaderVariable("uniform", "bool", f"iMouse{i}", self.mouse_buttons[i])
|
|
909
|
+
|
|
910
|
+
# ---------------------------------------------------------------------------------------------|
|
|
911
|
+
# Internal window events
|
|
912
|
+
|
|
913
|
+
def __window_resize__(self, width: int, height: int) -> None:
|
|
914
|
+
|
|
915
|
+
# Don't listen to resizes when exporting, as the final resolution might be
|
|
916
|
+
# greater than the monitor and the window will resize down to fit
|
|
917
|
+
if self.exporting:
|
|
918
|
+
return
|
|
919
|
+
self.imgui.resize(width, height)
|
|
920
|
+
self._width, self._height = width, height
|
|
921
|
+
self.relay(ShaderMessage.Shader.RecreateTextures)
|
|
922
|
+
self.relay(ShaderMessage.Shader.Render)
|
|
923
|
+
|
|
924
|
+
def __window_close__(self) -> None:
|
|
925
|
+
self.relay(ShaderMessage.Window.Close())
|
|
926
|
+
|
|
927
|
+
def __window_iconify__(self, state: bool) -> None:
|
|
928
|
+
self.relay(ShaderMessage.Window.Iconify(state=state))
|
|
929
|
+
|
|
930
|
+
def __window_files_dropped_event__(self, *stuff: list[str]) -> None:
|
|
931
|
+
self.relay(ShaderMessage.Window.FileDrop(files=stuff[1]))
|
|
932
|
+
|
|
933
|
+
# # Keyboard related events
|
|
934
|
+
|
|
935
|
+
def __window_key_event__(self, key: int, action: int, modifiers: int) -> None:
|
|
936
|
+
self.imgui.key_event(key, action, modifiers)
|
|
937
|
+
if self.imguio.want_capture_keyboard and self.render_ui:
|
|
938
|
+
return
|
|
939
|
+
if action == ShaderKeyboard.Keys.ACTION_PRESS:
|
|
940
|
+
self.relay(ShaderMessage.Keyboard.KeyDown(key=key, modifiers=modifiers))
|
|
941
|
+
elif action == ShaderKeyboard.Keys.ACTION_RELEASE:
|
|
942
|
+
self.relay(ShaderMessage.Keyboard.KeyUp(key=key, modifiers=modifiers))
|
|
943
|
+
self.relay(ShaderMessage.Keyboard.Press(key=key, action=action, modifiers=modifiers))
|
|
944
|
+
|
|
945
|
+
def __window_unicode_char_entered__(self, char: str) -> None:
|
|
946
|
+
if self.imguio.want_capture_keyboard and self.render_ui:
|
|
947
|
+
return
|
|
948
|
+
self.relay(ShaderMessage.Keyboard.Unicode(char=char))
|
|
949
|
+
|
|
950
|
+
# # Mouse related events
|
|
951
|
+
|
|
952
|
+
mouse_gluv: Tuple[float, float] = Factory(lambda: (0, 0))
|
|
953
|
+
|
|
954
|
+
def __xy2uv__(self, x: int=0, y: int=0) -> dict[str, float]:
|
|
955
|
+
"""Convert a XY pixel coordinate into a Center-UV normalized coordinate"""
|
|
956
|
+
return dict(
|
|
957
|
+
u=2*(x/self.width - 0.5),
|
|
958
|
+
v=2*(y/self.height - 0.5)*(-1),
|
|
959
|
+
x=x, y=y,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
def __dxdy2dudv__(self, dx: int=0, dy: int=0) -> dict[str, float]:
|
|
963
|
+
"""Convert a dx dy pixel coordinate into a Center-UV normalized coordinate"""
|
|
964
|
+
return dict(
|
|
965
|
+
du=2*(dx/self.width)*(self.width/self.height),
|
|
966
|
+
dv=2*(dy/self.height)*(-1),
|
|
967
|
+
dx=dx, dy=dy,
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
mouse_buttons: Dict[int, bool] = Factory(lambda: {k: False for k in range(1, 6)})
|
|
971
|
+
|
|
972
|
+
def __window_mouse_press_event__(self, x: int, y: int, button: int) -> None:
|
|
973
|
+
self.imgui.mouse_press_event(x, y, button)
|
|
974
|
+
if self.imguio.want_capture_mouse and self.render_ui:
|
|
975
|
+
return
|
|
976
|
+
self.mouse_buttons[button] = True
|
|
977
|
+
self.relay(ShaderMessage.Mouse.Press(
|
|
978
|
+
**self.__xy2uv__(x, y),
|
|
979
|
+
button=button
|
|
980
|
+
))
|
|
981
|
+
|
|
982
|
+
def __window_mouse_release_event__(self, x: int, y: int, button: int) -> None:
|
|
983
|
+
self.imgui.mouse_release_event(x, y, button)
|
|
984
|
+
if self.imguio.want_capture_mouse and self.render_ui:
|
|
985
|
+
return
|
|
986
|
+
self.mouse_buttons[button] = False
|
|
987
|
+
self.relay(ShaderMessage.Mouse.Release(
|
|
988
|
+
**self.__xy2uv__(x, y),
|
|
989
|
+
button=button
|
|
990
|
+
))
|
|
991
|
+
|
|
992
|
+
mouse_inside: bool = False
|
|
993
|
+
|
|
994
|
+
def __window_mouse_enter_event__(self, inside: bool) -> None:
|
|
995
|
+
self.mouse_inside = inside
|
|
996
|
+
self.relay(ShaderMessage.Mouse.Enter(state=inside))
|
|
997
|
+
|
|
998
|
+
def __window_mouse_scroll_event__(self, dx: int, dy: int) -> None:
|
|
999
|
+
self.imgui.mouse_scroll_event(dx, dy)
|
|
1000
|
+
if self.imguio.want_capture_mouse and self.render_ui:
|
|
1001
|
+
return
|
|
1002
|
+
elif self.keyboard(ShaderKeyboard.Keys.LEFT_ALT):
|
|
1003
|
+
self.tempo.target += (dy)*0.2
|
|
1004
|
+
return
|
|
1005
|
+
self.relay(ShaderMessage.Mouse.Scroll(
|
|
1006
|
+
**self.__dxdy2dudv__(dx=dx, dy=dy)
|
|
1007
|
+
))
|
|
1008
|
+
|
|
1009
|
+
def __window_mouse_position_event__(self, x: int, y: int, dx: int, dy: int) -> None:
|
|
1010
|
+
self.imgui.mouse_position_event(x, y, dx, dy)
|
|
1011
|
+
if self.imguio.want_capture_mouse and self.render_ui:
|
|
1012
|
+
return
|
|
1013
|
+
self.relay(ShaderMessage.Mouse.Position(
|
|
1014
|
+
**self.__dxdy2dudv__(dx=dx, dy=dy),
|
|
1015
|
+
**self.__xy2uv__(x=x, y=y)
|
|
1016
|
+
))
|
|
1017
|
+
|
|
1018
|
+
_mouse_drag_time_factor: float = 4
|
|
1019
|
+
"""How much seconds to scroll in time when the mouse moves the full window height"""
|
|
1020
|
+
|
|
1021
|
+
def __window_mouse_drag_event__(self, x: int, y: int, dx: int, dy: int) -> None:
|
|
1022
|
+
self.imgui.mouse_drag_event(x, y, dx, dy)
|
|
1023
|
+
if self.imguio.want_capture_mouse and self.render_ui:
|
|
1024
|
+
return
|
|
1025
|
+
|
|
1026
|
+
# Rotate the camera on Shift
|
|
1027
|
+
if self.keyboard(ShaderKeyboard.Keys.LEFT_CTRL):
|
|
1028
|
+
cx, cy = (x-self.width/2), (y-self.height/2)
|
|
1029
|
+
angle = math.atan2(cy+dy, cx+dx) - math.atan2(cy, cx)
|
|
1030
|
+
if (abs(angle) > math.pi): angle -= 2*math.pi
|
|
1031
|
+
self.camera.rotate(self.camera.base_z, angle=math.degrees(angle))
|
|
1032
|
+
return
|
|
1033
|
+
|
|
1034
|
+
elif self.exclusive:
|
|
1035
|
+
self.camera.apply_zoom(-dy/500)
|
|
1036
|
+
self.camera.rotate(self.camera.base_z, angle=-dx/10)
|
|
1037
|
+
return
|
|
1038
|
+
|
|
1039
|
+
# Time Travel on Alt
|
|
1040
|
+
elif self.keyboard(ShaderKeyboard.Keys.LEFT_ALT):
|
|
1041
|
+
self.time -= self._mouse_drag_time_factor * (dy/self.height)
|
|
1042
|
+
return
|
|
1043
|
+
|
|
1044
|
+
self.relay(ShaderMessage.Mouse.Drag(
|
|
1045
|
+
**self.__dxdy2dudv__(dx=dx, dy=dy),
|
|
1046
|
+
**self.__xy2uv__(x=x, y=y)
|
|
1047
|
+
))
|
|
1048
|
+
|
|
1049
|
+
# ---------------------------------------------------------------------------------------------|
|
|
1050
|
+
# Todo: Move UI to own class: For main menu, settings, exporting, etc
|
|
1051
|
+
|
|
1052
|
+
render_ui: bool = False
|
|
1053
|
+
"""Whether to render the Main UI"""
|
|
1054
|
+
|
|
1055
|
+
# Fixme: Move to somewhere better
|
|
1056
|
+
def _render_ui(self):
|
|
1057
|
+
if not self.render_ui:
|
|
1058
|
+
return
|
|
1059
|
+
|
|
1060
|
+
self._final.texture.fbo().use()
|
|
1061
|
+
imgui.push_style_var(imgui.STYLE_WINDOW_BORDERSIZE, 0.0)
|
|
1062
|
+
imgui.push_style_var(imgui.STYLE_WINDOW_ROUNDING, 8)
|
|
1063
|
+
imgui.push_style_var(imgui.STYLE_TAB_ROUNDING, 8)
|
|
1064
|
+
imgui.push_style_var(imgui.STYLE_GRAB_ROUNDING, 8)
|
|
1065
|
+
imgui.push_style_var(imgui.STYLE_FRAME_ROUNDING, 8)
|
|
1066
|
+
imgui.push_style_var(imgui.STYLE_CHILD_ROUNDING, 8)
|
|
1067
|
+
imgui.push_style_color(imgui.COLOR_FRAME_BACKGROUND, 0.1, 0.1, 0.1, 0.5)
|
|
1068
|
+
imgui.new_frame()
|
|
1069
|
+
imgui.set_next_window_position(0, 0)
|
|
1070
|
+
imgui.set_next_window_bg_alpha(0.6)
|
|
1071
|
+
imgui.begin(f"{self.__name__}", False, imgui.WINDOW_NO_MOVE | imgui.WINDOW_NO_RESIZE | imgui.WINDOW_NO_COLLAPSE | imgui.WINDOW_ALWAYS_AUTO_RESIZE)
|
|
1072
|
+
|
|
1073
|
+
# Render every module
|
|
1074
|
+
for module in self.modules:
|
|
1075
|
+
if imgui.tree_node(f"{module.uuid:>2} - {type(module).__name__.replace('ShaderFlow', '')}", imgui.TREE_NODE_BULLET | imgui.TREE_NODE_DEFAULT_OPEN):
|
|
1076
|
+
module.__shaderflow_ui__()
|
|
1077
|
+
imgui.spacing()
|
|
1078
|
+
imgui.tree_pop()
|
|
1079
|
+
|
|
1080
|
+
imgui.end()
|
|
1081
|
+
imgui.pop_style_color()
|
|
1082
|
+
imgui.pop_style_var(6)
|
|
1083
|
+
imgui.render()
|
|
1084
|
+
self.imgui.render(imgui.get_draw_data())
|
|
1085
|
+
|
|
1086
|
+
def __ui__(self) -> None:
|
|
1087
|
+
|
|
1088
|
+
# Render status
|
|
1089
|
+
imgui.text(f"Resolution: {self.render_resolution} -> {self.resolution} @ {self.ssaa:.2f}x SSAA")
|
|
1090
|
+
|
|
1091
|
+
# Framerate
|
|
1092
|
+
imgui.spacing()
|
|
1093
|
+
if (state := imgui.slider_float("Framerate", self.fps, 10, 240, "%.0f"))[0]:
|
|
1094
|
+
self.fps = round(state[1])
|
|
1095
|
+
for fps in (options := [24, 30, 60, 120, 144, 240]):
|
|
1096
|
+
if (state := imgui.button(f"{fps} Hz")):
|
|
1097
|
+
self.fps = fps
|
|
1098
|
+
if fps != options[-1]:
|
|
1099
|
+
imgui.same_line()
|
|
1100
|
+
|
|
1101
|
+
# Temporal
|
|
1102
|
+
imgui.spacing()
|
|
1103
|
+
if (state := imgui.slider_float("Time Scale", self.tempo.target, -2, 2, "%.2f"))[0]:
|
|
1104
|
+
self.tempo.target = state[1]
|
|
1105
|
+
for scale in (options := [-10, -5, -2, -1, 0, 1, 2, 5, 10]):
|
|
1106
|
+
if (state := imgui.button(f"{scale}x")):
|
|
1107
|
+
self.tempo.target = scale
|
|
1108
|
+
if scale != options[-1]:
|
|
1109
|
+
imgui.same_line()
|
|
1110
|
+
|
|
1111
|
+
# SSAA
|
|
1112
|
+
imgui.spacing()
|
|
1113
|
+
if (state := imgui.slider_float("SSAA", self.ssaa, 0.01, 2, "%.2f"))[0]:
|
|
1114
|
+
self.ssaa = state[1]
|
|
1115
|
+
for ssaa in (options := [0.1, 0.25, 0.5, 1.0, 1.25, 1.5, 2.0]):
|
|
1116
|
+
if (state := imgui.button(f"{ssaa}x")):
|
|
1117
|
+
self.ssaa = ssaa
|
|
1118
|
+
if ssaa != options[-1]:
|
|
1119
|
+
imgui.same_line()
|
|
1120
|
+
|
|
1121
|
+
# Quality
|
|
1122
|
+
imgui.spacing()
|
|
1123
|
+
if (state := imgui.slider_float("Quality", self.quality, 0, 100, "%.0f%%"))[0]:
|
|
1124
|
+
self.quality = state[1]
|