vimol 0.1.0__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.
vimol/gl_render.py ADDED
@@ -0,0 +1,742 @@
1
+ """Generic GPU sphere/cylinder impostor renderer (OpenGL, via moderngl).
2
+
3
+ This module knows nothing about molecules, atoms, bonds, or vimol's
4
+ ``Camera``/``Style`` types. It renders batches of analytic spheres and
5
+ cylinders — the same "impostor" technique as the CPU raycaster in
6
+ ``render.py`` (each primitive is a screen-aligned billboard whose fragment
7
+ shader solves the ray/surface intersection analytically), just executed on
8
+ the GPU with a real depth buffer instead of a manually-maintained z-buffer
9
+ array. Callers supply a raw 4x4 projection matrix and plain shading
10
+ parameters; nothing here is vimol-specific, so it's reusable outside this
11
+ project. See ``gl_adapter.py`` for the vimol-specific glue that turns a
12
+ ``Molecule`` + ``Camera`` + ``Style`` into the inputs this module expects.
13
+
14
+ Requires the optional ``moderngl`` dependency (``pip install vimol[gl]``).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from typing import Tuple
20
+
21
+ import numpy as np
22
+
23
+ import moderngl
24
+
25
+
26
+ @dataclass
27
+ class SphereBatch:
28
+ """A batch of analytic spheres, structure-of-arrays style.
29
+
30
+ ``centers``/``radii``/``colors`` must all be in the same coordinate
31
+ space as the ``proj`` matrix passed to :meth:`GLRenderer.render`
32
+ (typically view space, with the convention that larger Z is nearer the
33
+ viewer — see ``gl_adapter.py``).
34
+ """
35
+ centers: np.ndarray # (N, 3) float
36
+ radii: np.ndarray # (N,) float
37
+ colors: np.ndarray # (N, 3) float, 0..1
38
+
39
+ @staticmethod
40
+ def empty() -> "SphereBatch":
41
+ return SphereBatch(
42
+ centers=np.zeros((0, 3), np.float32),
43
+ radii=np.zeros((0,), np.float32),
44
+ colors=np.zeros((0, 3), np.float32),
45
+ )
46
+
47
+
48
+ @dataclass
49
+ class CylinderBatch:
50
+ """A batch of analytic cylinders (capped, but caps aren't drawn — bonds
51
+ are always occluded/occluding via the shared depth buffer instead)."""
52
+ a: np.ndarray # (M, 3) float — one endpoint
53
+ b: np.ndarray # (M, 3) float — other endpoint
54
+ radii: np.ndarray # (M,) float
55
+ colors_a: np.ndarray # (M, 3) float — color at the `a` end
56
+ colors_b: np.ndarray # (M, 3) float — color at the `b` end
57
+
58
+ @staticmethod
59
+ def empty() -> "CylinderBatch":
60
+ return CylinderBatch(
61
+ a=np.zeros((0, 3), np.float32),
62
+ b=np.zeros((0, 3), np.float32),
63
+ radii=np.zeros((0,), np.float32),
64
+ colors_a=np.zeros((0, 3), np.float32),
65
+ colors_b=np.zeros((0, 3), np.float32),
66
+ )
67
+
68
+
69
+ @dataclass
70
+ class ConeBatch:
71
+ """A batch of analytic cones (arrow heads): radius *radius* at ``base``,
72
+ tapering linearly to a point at ``apex``. Used for the arrowhead half of
73
+ a vector-field arrow; the shaft half is a plain ``CylinderBatch`` entry."""
74
+ base: np.ndarray # (K, 3) float
75
+ apex: np.ndarray # (K, 3) float
76
+ radius: np.ndarray # (K,) float
77
+ color: np.ndarray # (K, 3) float
78
+
79
+ @staticmethod
80
+ def empty() -> "ConeBatch":
81
+ return ConeBatch(
82
+ base=np.zeros((0, 3), np.float32),
83
+ apex=np.zeros((0, 3), np.float32),
84
+ radius=np.zeros((0,), np.float32),
85
+ color=np.zeros((0, 3), np.float32),
86
+ )
87
+
88
+
89
+ @dataclass
90
+ class ShadingParams:
91
+ """Plain Phong/fog shading parameters — no representation/chemistry
92
+ concepts, unlike ``render.Style``."""
93
+ ambient: float = 0.28
94
+ specular_strength: float = 0.55
95
+ shininess: float = 32.0
96
+ light_dir: Tuple[float, float, float] = (0.35, 0.55, 0.75)
97
+ fill_light: float = 0.25
98
+ depth_cue: float = 0.55
99
+ background: Tuple[float, float, float] = (0.05, 0.06, 0.09)
100
+ transparent: bool = False
101
+
102
+
103
+ _ATOM_VERT = """
104
+ #version 410 core
105
+ layout(location = 0) in vec2 in_corner;
106
+ layout(location = 1) in vec3 in_center;
107
+ layout(location = 2) in float in_radius;
108
+ layout(location = 3) in vec3 in_color;
109
+
110
+ uniform mat4 u_proj;
111
+
112
+ out vec2 v_offset;
113
+ out vec3 v_center;
114
+ out float v_radius;
115
+ out vec3 v_color;
116
+
117
+ void main() {
118
+ v_offset = in_corner * in_radius;
119
+ v_center = in_center;
120
+ v_radius = in_radius;
121
+ v_color = in_color;
122
+ gl_Position = u_proj * vec4(in_center.xy + v_offset, in_center.z, 1.0);
123
+ }
124
+ """
125
+
126
+ _ATOM_FRAG = """
127
+ #version 410 core
128
+ in vec2 v_offset;
129
+ in vec3 v_center;
130
+ in float v_radius;
131
+ in vec3 v_color;
132
+
133
+ uniform float u_proj_z_scale;
134
+ uniform float u_proj_z_bias;
135
+ uniform vec3 u_light_dir;
136
+ uniform vec3 u_half_vec;
137
+ uniform vec3 u_fill_dir;
138
+ uniform float u_ambient;
139
+ uniform float u_fill_light;
140
+ uniform float u_specular_strength;
141
+ uniform float u_shininess;
142
+ uniform float u_depth_cue;
143
+ uniform float u_zmin;
144
+ uniform float u_zspan;
145
+
146
+ out vec4 frag_color;
147
+
148
+ void main() {
149
+ float ox = v_offset.x;
150
+ float oy = v_offset.y;
151
+ float r = v_radius;
152
+ float h2 = r * r - ox * ox - oy * oy;
153
+ if (h2 < 0.0) discard;
154
+ float h = sqrt(h2);
155
+ float depth_view = v_center.z + h;
156
+ vec3 normal = vec3(ox / r, oy / r, h / r);
157
+
158
+ float nl = clamp(dot(normal, u_light_dir), 0.0, 1.0);
159
+ float nf = clamp(dot(normal, u_fill_dir), 0.0, 1.0) * u_fill_light;
160
+ float nh = clamp(dot(normal, u_half_vec), 0.0, 1.0);
161
+ float spec = pow(nh, u_shininess) * u_specular_strength;
162
+ float diff = u_ambient + nl + nf;
163
+ vec3 shaded = v_color * diff + vec3(spec);
164
+
165
+ if (u_depth_cue > 0.0) {
166
+ float f = clamp((depth_view - u_zmin) / u_zspan, 0.0, 1.0);
167
+ float fog = 1.0 - u_depth_cue * (1.0 - f);
168
+ shaded *= fog;
169
+ }
170
+
171
+ frag_color = vec4(shaded, 1.0);
172
+ float nz = u_proj_z_scale * depth_view + u_proj_z_bias;
173
+ gl_FragDepth = nz * 0.5 + 0.5;
174
+ }
175
+ """
176
+
177
+ _BOND_VERT = """
178
+ #version 410 core
179
+ layout(location = 0) in vec2 in_corner;
180
+ layout(location = 1) in vec3 in_a;
181
+ layout(location = 2) in vec3 in_b;
182
+ layout(location = 3) in float in_radius;
183
+ layout(location = 4) in vec3 in_color_a;
184
+ layout(location = 5) in vec3 in_color_b;
185
+
186
+ uniform mat4 u_proj;
187
+
188
+ out vec2 v_pixel;
189
+ out vec3 v_a;
190
+ out vec3 v_b;
191
+ out float v_radius;
192
+ out vec3 v_color_a;
193
+ out vec3 v_color_b;
194
+
195
+ void main() {
196
+ vec2 lo = min(in_a.xy, in_b.xy) - vec2(in_radius);
197
+ vec2 hi = max(in_a.xy, in_b.xy) + vec2(in_radius);
198
+ vec2 center = 0.5 * (lo + hi);
199
+ vec2 half_extent = max(0.5 * (hi - lo), vec2(1e-4));
200
+ vec2 pos = center + in_corner * half_extent;
201
+
202
+ v_pixel = pos;
203
+ v_a = in_a;
204
+ v_b = in_b;
205
+ v_radius = in_radius;
206
+ v_color_a = in_color_a;
207
+ v_color_b = in_color_b;
208
+
209
+ float z_mid = 0.5 * (in_a.z + in_b.z);
210
+ gl_Position = u_proj * vec4(pos, z_mid, 1.0);
211
+ }
212
+ """
213
+
214
+ _BOND_FRAG = """
215
+ #version 410 core
216
+ in vec2 v_pixel;
217
+ in vec3 v_a;
218
+ in vec3 v_b;
219
+ in float v_radius;
220
+ in vec3 v_color_a;
221
+ in vec3 v_color_b;
222
+
223
+ uniform float u_proj_z_scale;
224
+ uniform float u_proj_z_bias;
225
+ uniform vec3 u_light_dir;
226
+ uniform vec3 u_half_vec;
227
+ uniform vec3 u_fill_dir;
228
+ uniform float u_ambient;
229
+ uniform float u_fill_light;
230
+ uniform float u_specular_strength;
231
+ uniform float u_shininess;
232
+ uniform float u_depth_cue;
233
+ uniform float u_zmin;
234
+ uniform float u_zspan;
235
+
236
+ out vec4 frag_color;
237
+
238
+ void main() {
239
+ vec3 axis = v_b - v_a;
240
+ float L = length(axis);
241
+ if (L < 1e-6) discard;
242
+ vec3 u = axis / L;
243
+
244
+ float ex = v_pixel.x - v_a.x;
245
+ float ey = v_pixel.y - v_a.y;
246
+ float uz = u.z;
247
+ float A0 = ex * u.x + ey * u.y;
248
+ float a2 = 1.0 - uz * uz;
249
+ float c2 = ex * ex + ey * ey - A0 * A0 - v_radius * v_radius;
250
+
251
+ float s;
252
+ bool hit;
253
+ if (abs(a2) < 1e-9) {
254
+ hit = (-c2) >= 0.0;
255
+ s = 0.0;
256
+ } else {
257
+ float b2 = -2.0 * A0 * uz;
258
+ float disc = b2 * b2 - 4.0 * a2 * c2;
259
+ hit = disc >= 0.0;
260
+ float sq = sqrt(max(disc, 0.0));
261
+ s = (-b2 + sq) / (2.0 * a2);
262
+ }
263
+ if (!hit) discard;
264
+
265
+ float t = A0 + uz * s;
266
+ if (t < 0.0 || t > L) discard;
267
+
268
+ float depth_view = v_a.z + s;
269
+
270
+ float proj_t = t;
271
+ vec3 normal = vec3((ex - proj_t * u.x) / v_radius,
272
+ (ey - proj_t * u.y) / v_radius,
273
+ (s - proj_t * u.z) / v_radius);
274
+
275
+ float frac = t / L;
276
+ vec3 albedo = (frac < 0.5) ? v_color_a : v_color_b;
277
+
278
+ float nl = clamp(dot(normal, u_light_dir), 0.0, 1.0);
279
+ float nf = clamp(dot(normal, u_fill_dir), 0.0, 1.0) * u_fill_light;
280
+ float nh = clamp(dot(normal, u_half_vec), 0.0, 1.0);
281
+ float spec = pow(nh, u_shininess) * u_specular_strength;
282
+ float diff = u_ambient + nl + nf;
283
+ vec3 shaded = albedo * diff + vec3(spec);
284
+
285
+ if (u_depth_cue > 0.0) {
286
+ float f = clamp((depth_view - u_zmin) / u_zspan, 0.0, 1.0);
287
+ float fog = 1.0 - u_depth_cue * (1.0 - f);
288
+ shaded *= fog;
289
+ }
290
+
291
+ frag_color = vec4(shaded, 1.0);
292
+ float nz = u_proj_z_scale * depth_view + u_proj_z_bias;
293
+ gl_FragDepth = nz * 0.5 + 0.5;
294
+ }
295
+ """
296
+
297
+ _CONE_VERT = """
298
+ #version 410 core
299
+ layout(location = 0) in vec2 in_corner;
300
+ layout(location = 1) in vec3 in_base;
301
+ layout(location = 2) in vec3 in_apex;
302
+ layout(location = 3) in float in_radius;
303
+ layout(location = 4) in vec3 in_color;
304
+
305
+ uniform mat4 u_proj;
306
+
307
+ out vec2 v_pixel;
308
+ out vec3 v_base;
309
+ out vec3 v_apex;
310
+ out float v_radius;
311
+ out vec3 v_color;
312
+
313
+ void main() {
314
+ // Only the base has nonzero radius (the apex tapers to a point), but
315
+ // padding the bbox by in_radius at both corners is still a safe
316
+ // (slightly generous near the apex) bound -- same approach _BOND_VERT
317
+ // uses for a cylinder's uniform radius.
318
+ vec2 lo = min(in_base.xy, in_apex.xy) - vec2(in_radius);
319
+ vec2 hi = max(in_base.xy, in_apex.xy) + vec2(in_radius);
320
+ vec2 center = 0.5 * (lo + hi);
321
+ vec2 half_extent = max(0.5 * (hi - lo), vec2(1e-4));
322
+ vec2 pos = center + in_corner * half_extent;
323
+
324
+ v_pixel = pos;
325
+ v_base = in_base;
326
+ v_apex = in_apex;
327
+ v_radius = in_radius;
328
+ v_color = in_color;
329
+
330
+ float z_mid = 0.5 * (in_base.z + in_apex.z);
331
+ gl_Position = u_proj * vec4(pos, z_mid, 1.0);
332
+ }
333
+ """
334
+
335
+ _CONE_FRAG = """
336
+ #version 410 core
337
+ in vec2 v_pixel;
338
+ in vec3 v_base;
339
+ in vec3 v_apex;
340
+ in float v_radius;
341
+ in vec3 v_color;
342
+
343
+ uniform float u_proj_z_scale;
344
+ uniform float u_proj_z_bias;
345
+ uniform vec3 u_light_dir;
346
+ uniform vec3 u_half_vec;
347
+ uniform vec3 u_fill_dir;
348
+ uniform float u_ambient;
349
+ uniform float u_fill_light;
350
+ uniform float u_specular_strength;
351
+ uniform float u_shininess;
352
+ uniform float u_depth_cue;
353
+ uniform float u_zmin;
354
+ uniform float u_zspan;
355
+
356
+ out vec4 frag_color;
357
+
358
+ // GLSL port of render.Renderer._draw_cone_segment's math -- see that
359
+ // docstring for the derivation. Target radius at axial offset s is
360
+ // C + D*s (linear from R at the base to 0 at the apex); this folds an
361
+ // extra (C+D*s)^2 term into the cylinder's usual quadratic, so unlike the
362
+ // cylinder shader the leading coefficient a2p isn't sign-guaranteed and
363
+ // both roots must be checked, picking the nearer-camera (larger s) one
364
+ // that lies on the finite cone (0<=t<=L) with non-negative radius
365
+ // (rejecting the mirror nappe beyond the apex that squaring introduces).
366
+ void main() {
367
+ vec3 axis = v_apex - v_base;
368
+ float L = length(axis);
369
+ if (L < 1e-6 || v_radius <= 0.0) discard;
370
+ vec3 u = axis / L;
371
+ float R = v_radius;
372
+
373
+ float ex = v_pixel.x - v_base.x;
374
+ float ey = v_pixel.y - v_base.y;
375
+ float uz = u.z;
376
+ float A0 = ex * u.x + ey * u.y;
377
+ float a2 = 1.0 - uz * uz;
378
+ float b2 = -2.0 * A0 * uz;
379
+ float c2 = ex * ex + ey * ey - A0 * A0;
380
+
381
+ float k = R / L;
382
+ float C = R - k * A0;
383
+ float D = -k * uz;
384
+ float a2p = a2 - D * D;
385
+ float b2p = b2 - 2.0 * C * D;
386
+ float c2p = c2 - C * C;
387
+
388
+ bool near_parallel = abs(a2p) < 1e-9;
389
+ float safe_a2p = near_parallel ? 1.0 : a2p;
390
+ float disc = b2p * b2p - 4.0 * a2p * c2p;
391
+ bool has_root = disc >= 0.0;
392
+ float sq = sqrt(max(disc, 0.0));
393
+ float s_plus = (-b2p + sq) / (2.0 * safe_a2p);
394
+ float s_minus = (-b2p - sq) / (2.0 * safe_a2p);
395
+
396
+ bool b2p_nonzero = abs(b2p) >= 1e-12;
397
+ float safe_b2p = b2p_nonzero ? b2p : 1.0;
398
+ float s_lin = -c2p / safe_b2p;
399
+ bool lin_valid = near_parallel && b2p_nonzero;
400
+
401
+ float t_plus = A0 + uz * s_plus;
402
+ float r_plus = C + D * s_plus;
403
+ bool v_plus = has_root && !near_parallel && (t_plus >= 0.0) && (t_plus <= L) && (r_plus >= -1e-6);
404
+
405
+ float t_minus = A0 + uz * s_minus;
406
+ float r_minus = C + D * s_minus;
407
+ bool v_minus = has_root && !near_parallel && (t_minus >= 0.0) && (t_minus <= L) && (r_minus >= -1e-6);
408
+
409
+ float t_lin = A0 + uz * s_lin;
410
+ float r_lin = C + D * s_lin;
411
+ bool v_lin = lin_valid && (t_lin >= 0.0) && (t_lin <= L) && (r_lin >= -1e-6);
412
+
413
+ const float NEG_INF = -1e30;
414
+ float s = NEG_INF;
415
+ if (v_plus) s = max(s, s_plus);
416
+ if (v_minus) s = max(s, s_minus);
417
+ if (v_lin) s = max(s, s_lin);
418
+ if (s <= NEG_INF * 0.5) discard;
419
+
420
+ float t = A0 + uz * s;
421
+ float depth_view = v_base.z + s;
422
+
423
+ float wx = ex - t * u.x;
424
+ float wy = ey - t * u.y;
425
+ float wz = s - t * u.z;
426
+ float r_t = max(C + D * s, 1e-6);
427
+ vec3 normal = normalize(vec3(wx / r_t, wy / r_t, wz / r_t) * L + u * R);
428
+
429
+ float nl = clamp(dot(normal, u_light_dir), 0.0, 1.0);
430
+ float nf = clamp(dot(normal, u_fill_dir), 0.0, 1.0) * u_fill_light;
431
+ float nh = clamp(dot(normal, u_half_vec), 0.0, 1.0);
432
+ float spec = pow(nh, u_shininess) * u_specular_strength;
433
+ float diff = u_ambient + nl + nf;
434
+ vec3 shaded = v_color * diff + vec3(spec);
435
+
436
+ if (u_depth_cue > 0.0) {
437
+ float f = clamp((depth_view - u_zmin) / u_zspan, 0.0, 1.0);
438
+ float fog = 1.0 - u_depth_cue * (1.0 - f);
439
+ shaded *= fog;
440
+ }
441
+
442
+ frag_color = vec4(shaded, 1.0);
443
+ float nz = u_proj_z_scale * depth_view + u_proj_z_bias;
444
+ gl_FragDepth = nz * 0.5 + 0.5;
445
+ }
446
+ """
447
+
448
+ _QUAD_CORNERS = np.array([-1, -1, 1, -1, -1, 1, 1, 1], dtype=np.float32)
449
+
450
+ # Fullscreen-quad box-average downsample: for each output pixel, average the
451
+ # ds*ds block of source texels. Exact box filter (matches the CPU _downsample),
452
+ # and it runs on the GPU in ~1ms instead of ~200ms in numpy.
453
+ _DOWNSAMPLE_VERT = """
454
+ #version 410 core
455
+ layout(location = 0) in vec2 in_corner;
456
+ void main() { gl_Position = vec4(in_corner, 0.0, 1.0); }
457
+ """
458
+
459
+ _DOWNSAMPLE_FRAG = """
460
+ #version 410 core
461
+ uniform sampler2D u_src;
462
+ uniform int u_factor;
463
+ out vec4 frag_color;
464
+ void main() {
465
+ ivec2 base = ivec2(gl_FragCoord.xy) * u_factor;
466
+ vec4 acc = vec4(0.0);
467
+ for (int dy = 0; dy < u_factor; ++dy)
468
+ for (int dx = 0; dx < u_factor; ++dx)
469
+ acc += texelFetch(u_src, base + ivec2(dx, dy), 0);
470
+ acc /= float(u_factor * u_factor);
471
+ // The source averaged in premultiplied space (undrawn texels are 0,0,0,0),
472
+ // so divide back out to straight alpha -- what the Kitty/PNG encoders want.
473
+ // Opaque scenes have alpha==1 everywhere, making this a no-op there.
474
+ if (acc.a > 0.0) acc.rgb /= acc.a;
475
+ frag_color = acc;
476
+ }
477
+ """
478
+
479
+
480
+ def _create_standalone_context() -> moderngl.Context:
481
+ """Create a standalone context, preferring EGL over moderngl's default.
482
+
483
+ On Linux, ``moderngl.create_context(standalone=True)`` always picks the
484
+ GLX/X11 backend (see ``glcontext.default_backend``) regardless of
485
+ whether ``DISPLAY`` actually has working GL behind it -- an SSH session
486
+ with X11 forwarding sets ``DISPLAY`` to a forwarded/virtual X server
487
+ that has no real (or even software) GLX support, so GLX context
488
+ creation fails with a real GPU sitting right there. EGL's device
489
+ platform talks to the GPU directly via ``/dev/dri/renderD*``, bypassing
490
+ X entirely, so try it first and fall back to moderngl's default (GLX on
491
+ Linux, CGL on macOS) if EGL isn't available.
492
+ """
493
+ try:
494
+ return moderngl.create_context(standalone=True, backend="egl")
495
+ except Exception:
496
+ return moderngl.create_context(standalone=True)
497
+
498
+
499
+ class GLRenderer:
500
+ """GPU sphere/cylinder impostor renderer.
501
+
502
+ Mirrors ``render.Renderer``'s ``__init__(width, height)``/
503
+ ``resize(width, height)`` shape so callers can construct/resize either
504
+ renderer identically, but ``render()`` takes generic primitive batches
505
+ plus a raw projection matrix rather than a molecule-specific object.
506
+ """
507
+
508
+ def __init__(self, width: int, height: int):
509
+ self.ctx = _create_standalone_context()
510
+ self.width = int(width)
511
+ self.height = int(height)
512
+ self._quad_vbo = self.ctx.buffer(_QUAD_CORNERS.tobytes())
513
+ self._atom_program = self.ctx.program(vertex_shader=_ATOM_VERT, fragment_shader=_ATOM_FRAG)
514
+ self._bond_program = self.ctx.program(vertex_shader=_BOND_VERT, fragment_shader=_BOND_FRAG)
515
+ self._cone_program = self.ctx.program(vertex_shader=_CONE_VERT, fragment_shader=_CONE_FRAG)
516
+ self._downsample_program = self.ctx.program(
517
+ vertex_shader=_DOWNSAMPLE_VERT, fragment_shader=_DOWNSAMPLE_FRAG)
518
+ self._downsample_vao = self.ctx.vertex_array(
519
+ self._downsample_program, [(self._quad_vbo, "2f", "in_corner")])
520
+ self._fbo = None
521
+ self._color_tex = None # color attachment is a texture so the
522
+ self._depth_rb = None # downsample pass can sample it
523
+ self._resolve_fbo = None # smaller FBO the downsample pass writes to
524
+ self._resolve_size = None
525
+ self._build_fbo(self.width, self.height)
526
+
527
+ def _build_fbo(self, w: int, h: int) -> None:
528
+ if self._fbo is not None:
529
+ self._fbo.release()
530
+ self._color_tex.release()
531
+ self._depth_rb.release()
532
+ self._color_tex = self.ctx.texture((w, h), components=4, dtype="f1")
533
+ self._depth_rb = self.ctx.depth_renderbuffer((w, h))
534
+ self._fbo = self.ctx.framebuffer(color_attachments=[self._color_tex], depth_attachment=self._depth_rb)
535
+
536
+ def _resolve_target(self, w: int, h: int):
537
+ """A cached (rebuilt on size change) color-only FBO at (w, h) that the
538
+ box-average downsample pass renders into."""
539
+ if self._resolve_size != (w, h):
540
+ if self._resolve_fbo is not None:
541
+ self._resolve_fbo.release()
542
+ self._resolve_rb.release()
543
+ self._resolve_rb = self.ctx.renderbuffer((w, h), components=4, dtype="f1")
544
+ self._resolve_fbo = self.ctx.framebuffer(color_attachments=[self._resolve_rb])
545
+ self._resolve_size = (w, h)
546
+ return self._resolve_fbo
547
+
548
+ def resize(self, width: int, height: int) -> None:
549
+ width, height = int(width), int(height)
550
+ if (width, height) == (self.width, self.height):
551
+ return
552
+ self.width, self.height = width, height
553
+ self._build_fbo(width, height)
554
+
555
+ # ------------------------------------------------------------------
556
+ def render(self, spheres: SphereBatch, cylinders: CylinderBatch,
557
+ proj: np.ndarray, shading: ShadingParams | None = None,
558
+ downsample: int = 1, cones: ConeBatch | None = None) -> np.ndarray:
559
+ """Render *spheres*, *cylinders* and *cones* under projection *proj*.
560
+
561
+ *cones* defaults to empty so existing call sites that only know about
562
+ spheres/cylinders keep working unchanged.
563
+
564
+ *proj* is a 4x4 matrix in the usual mathematical convention
565
+ (``[nx,ny,nz,1] = proj @ [vx,vy,vz,1]``, row-major indexing) mapping
566
+ the primitives' coordinate space to clip-space NDC. Its ``[2,2]``/
567
+ ``[2,3]`` entries double as the scale/bias used to reproject each
568
+ fragment's own analytic depth (see ``gl_adapter._build_projection``
569
+ for how vimol builds one from its orthographic ``Camera``).
570
+
571
+ *downsample* (>=1) supersamples: the scene is drawn at the full
572
+ ``(width, height)`` and box-averaged down by this factor **on the GPU**
573
+ (a fullscreen downsample-shader pass, ~1ms) rather than in numpy on the
574
+ CPU (~200ms at full screen). The returned image is therefore
575
+ ``(height//downsample, width//downsample)``. The caller sizes this
576
+ renderer to display-size*factor and passes the same factor here.
577
+ """
578
+ shading = shading or ShadingParams()
579
+ cones = cones or ConeBatch.empty()
580
+ W, H = self.width, self.height
581
+ downsample = max(1, int(downsample))
582
+
583
+ proj = np.asarray(proj, dtype=np.float64)
584
+ z_scale = float(proj[2, 2])
585
+ z_bias = float(proj[2, 3])
586
+ proj_gl = np.ascontiguousarray(proj.T, dtype=np.float32) # column-major for GLSL
587
+
588
+ centers = np.asarray(spheres.centers, dtype=np.float32).reshape(-1, 3)
589
+ radii_s = np.asarray(spheres.radii, dtype=np.float32).reshape(-1)
590
+ colors_s = np.asarray(spheres.colors, dtype=np.float32).reshape(-1, 3)
591
+ keep_s = radii_s > 0
592
+ centers, radii_s, colors_s = centers[keep_s], radii_s[keep_s], colors_s[keep_s]
593
+
594
+ ca = np.asarray(cylinders.a, dtype=np.float32).reshape(-1, 3)
595
+ cb = np.asarray(cylinders.b, dtype=np.float32).reshape(-1, 3)
596
+ radii_c = np.asarray(cylinders.radii, dtype=np.float32).reshape(-1)
597
+ colors_a = np.asarray(cylinders.colors_a, dtype=np.float32).reshape(-1, 3)
598
+ colors_b = np.asarray(cylinders.colors_b, dtype=np.float32).reshape(-1, 3)
599
+ keep_c = radii_c > 0
600
+ ca, cb, radii_c, colors_a, colors_b = (
601
+ ca[keep_c], cb[keep_c], radii_c[keep_c], colors_a[keep_c], colors_b[keep_c],
602
+ )
603
+
604
+ cone_base = np.asarray(cones.base, dtype=np.float32).reshape(-1, 3)
605
+ cone_apex = np.asarray(cones.apex, dtype=np.float32).reshape(-1, 3)
606
+ radii_h = np.asarray(cones.radius, dtype=np.float32).reshape(-1)
607
+ colors_h = np.asarray(cones.color, dtype=np.float32).reshape(-1, 3)
608
+ keep_h = radii_h > 0
609
+ cone_base, cone_apex, radii_h, colors_h = (
610
+ cone_base[keep_h], cone_apex[keep_h], radii_h[keep_h], colors_h[keep_h],
611
+ )
612
+
613
+ if len(centers) or len(ca) or len(cone_base):
614
+ depths = np.concatenate([centers[:, 2], ca[:, 2], cb[:, 2], cone_base[:, 2], cone_apex[:, 2]])
615
+ zmin = float(depths.min())
616
+ zspan = max(float(depths.max()) - zmin, 1e-6)
617
+ else:
618
+ zmin, zspan = 0.0, 1.0
619
+
620
+ light = _normalize(np.asarray(shading.light_dir, dtype=np.float64))
621
+ halfv = _normalize(light + np.array([0.0, 0.0, 1.0]))
622
+ fill = _normalize(np.array([-light[0], -light[1], 0.6]))
623
+
624
+ self._fbo.use()
625
+ self.ctx.viewport = (0, 0, W, H)
626
+ if shading.transparent:
627
+ self._fbo.clear(0.0, 0.0, 0.0, 0.0, depth=1.0)
628
+ else:
629
+ bg = shading.background
630
+ self._fbo.clear(bg[0], bg[1], bg[2], 1.0, depth=1.0)
631
+ self.ctx.enable(moderngl.DEPTH_TEST)
632
+ self.ctx.disable(moderngl.BLEND)
633
+ self.ctx.depth_func = "<"
634
+
635
+ common = dict(
636
+ u_proj_z_scale=z_scale, u_proj_z_bias=z_bias,
637
+ u_light_dir=tuple(light), u_half_vec=tuple(halfv), u_fill_dir=tuple(fill),
638
+ u_ambient=shading.ambient, u_fill_light=shading.fill_light,
639
+ u_specular_strength=shading.specular_strength, u_shininess=shading.shininess,
640
+ u_depth_cue=shading.depth_cue, u_zmin=zmin, u_zspan=zspan,
641
+ )
642
+
643
+ if len(ca):
644
+ self._draw_bonds(ca, cb, radii_c, colors_a, colors_b, proj_gl, common)
645
+ if len(centers):
646
+ self._draw_atoms(centers, radii_s, colors_s, proj_gl, common)
647
+ if len(cone_base):
648
+ self._draw_cones(cone_base, cone_apex, radii_h, colors_h, proj_gl, common)
649
+
650
+ if downsample > 1:
651
+ tw, th = W // downsample, H // downsample
652
+ resolve = self._resolve_target(tw, th)
653
+ resolve.use()
654
+ self.ctx.viewport = (0, 0, tw, th)
655
+ self.ctx.disable(moderngl.DEPTH_TEST)
656
+ self._color_tex.use(location=0)
657
+ self._downsample_program["u_src"] = 0
658
+ self._downsample_program["u_factor"] = downsample
659
+ self._downsample_vao.render(moderngl.TRIANGLE_STRIP)
660
+ raw = resolve.read(components=4, dtype="f1")
661
+ arr = np.frombuffer(raw, dtype=np.uint8).reshape(th, tw, 4)
662
+ else:
663
+ raw = self._fbo.read(components=4, dtype="f1")
664
+ arr = np.frombuffer(raw, dtype=np.uint8).reshape(H, W, 4)
665
+ arr = np.flipud(arr)
666
+
667
+ # Edge pixels come back straight-alpha already (the downsample shader
668
+ # un-premultiplies; the ds==1 path never premultiplies).
669
+ if not shading.transparent:
670
+ return np.ascontiguousarray(arr[..., :3])
671
+ return np.ascontiguousarray(arr)
672
+
673
+ # ------------------------------------------------------------------
674
+ def _set_uniforms(self, program, proj_gl: np.ndarray, common: dict) -> None:
675
+ program["u_proj"].write(proj_gl.tobytes())
676
+ for name, value in common.items():
677
+ program[name].value = value
678
+
679
+ def _draw_atoms(self, centers, radii, colors, proj_gl, common) -> None:
680
+ n = centers.shape[0]
681
+ data = np.empty((n, 7), dtype=np.float32)
682
+ data[:, 0:3] = centers
683
+ data[:, 3] = radii
684
+ data[:, 4:7] = colors
685
+ inst_vbo = self.ctx.buffer(data.tobytes())
686
+ vao = self.ctx.vertex_array(
687
+ self._atom_program,
688
+ [
689
+ (self._quad_vbo, "2f", "in_corner"),
690
+ (inst_vbo, "3f 1f 3f/i", "in_center", "in_radius", "in_color"),
691
+ ],
692
+ )
693
+ self._set_uniforms(self._atom_program, proj_gl, common)
694
+ vao.render(moderngl.TRIANGLE_STRIP, instances=n)
695
+ vao.release()
696
+ inst_vbo.release()
697
+
698
+ def _draw_bonds(self, a, b, radii, colors_a, colors_b, proj_gl, common) -> None:
699
+ n = a.shape[0]
700
+ data = np.empty((n, 13), dtype=np.float32)
701
+ data[:, 0:3] = a
702
+ data[:, 3:6] = b
703
+ data[:, 6] = radii
704
+ data[:, 7:10] = colors_a
705
+ data[:, 10:13] = colors_b
706
+ inst_vbo = self.ctx.buffer(data.tobytes())
707
+ vao = self.ctx.vertex_array(
708
+ self._bond_program,
709
+ [
710
+ (self._quad_vbo, "2f", "in_corner"),
711
+ (inst_vbo, "3f 3f 1f 3f 3f/i", "in_a", "in_b", "in_radius", "in_color_a", "in_color_b"),
712
+ ],
713
+ )
714
+ self._set_uniforms(self._bond_program, proj_gl, common)
715
+ vao.render(moderngl.TRIANGLE_STRIP, instances=n)
716
+ vao.release()
717
+ inst_vbo.release()
718
+
719
+ def _draw_cones(self, base, apex, radii, colors, proj_gl, common) -> None:
720
+ n = base.shape[0]
721
+ data = np.empty((n, 10), dtype=np.float32)
722
+ data[:, 0:3] = base
723
+ data[:, 3:6] = apex
724
+ data[:, 6] = radii
725
+ data[:, 7:10] = colors
726
+ inst_vbo = self.ctx.buffer(data.tobytes())
727
+ vao = self.ctx.vertex_array(
728
+ self._cone_program,
729
+ [
730
+ (self._quad_vbo, "2f", "in_corner"),
731
+ (inst_vbo, "3f 3f 1f 3f/i", "in_base", "in_apex", "in_radius", "in_color"),
732
+ ],
733
+ )
734
+ self._set_uniforms(self._cone_program, proj_gl, common)
735
+ vao.render(moderngl.TRIANGLE_STRIP, instances=n)
736
+ vao.release()
737
+ inst_vbo.release()
738
+
739
+
740
+ def _normalize(v: np.ndarray) -> np.ndarray:
741
+ n = np.linalg.norm(v)
742
+ return v / n if n > 1e-12 else v