Skip to main content

Geometry extraction

Can PyMOL’s already-computed render geometry be pulled out of the C++ backend and shipped to a browser, so the browser draws it instead of recomputing representations from atoms? This is the inventory that answered that, read out of packages/engine/. Every claim carries a file:line. Where something could not be confirmed by reading code, the text says so. Where the port stands. Yes, and it is built. _cmd.web_get_rep_geometry is the accessor of §6 (packages/engine/layer4/Cmd.cpp:6471, implementation packages/engine/layer4/CmdWebGeometry.cpp); it is served by packages/bridge/tenmol_bridge/render/modeg.py, typed in packages/protocol/src/geometry.ts, and drawn by packages/viewport/src/modeG/. The raster fallback for everything Mode G cannot draw is packages/viewport/src/modeP/, with the per-rep switch in packages/viewport/src/renderPolicy.ts. Measurements are in docs/spikes/geometry.md and docs/spikes/geometry-accessor.md.

0. TL;DR

  • YES, PyMOL keeps a complete, CPU-resident, GL-free description of every representation’s geometry. It lives in two forms: (a) plain std::vector<float> triangle soup for surfaces (packages/engine/layer2/RepSurface.cpp:71-83), and (b) CGO buffers — a flat float* bytecode with a documented opcode set — for every other rep (packages/engine/layer1/CGO.h:82-270, packages/engine/layer1/CGO.h:765-773).
  • NO, none of that is exposed to Python today for molecular reps. The only Python-visible geometry exports are cmd.get_vrml / get_collada / get_idtf / get_povray / get_mtl_obj / get_gltf (packages/engine/modules/pymol/exporting.py:1012-1019), and all six of them route through the ray tracer (packages/engine/layer1/SceneRay.cpp:336-491), not through the GL/CGO buffers. They are ASCII, per-primitive, camera-space, lossy, and multi-second. None is usable as an interactive feed.
  • The exception is surfaces: cache_mode >= 2 already serializes the surface triangle mesh (N, V, VN, NT, T, S) into a Python tuple that lands in pymol._cache (packages/engine/layer2/RepSurface.cpp:3217-3229, packages/engine/layer2/RepSurface.cpp:4559-4574, packages/engine/modules/pymol/internal.py:101). That is a real, existing, zero-new-C++ path to a surface mesh — but Python lists of boxed floats, and no colors.
  • The answer that shipped is a new CPython accessor that walks CoordSet::Rep[cRepCnt] (packages/engine/layer2/CoordSet.h:107), runs the existing CPU tessellators CGOSimplify() (packages/engine/layer1/CGO.cpp:4444) + CGOCombineBeginEnd() (packages/engine/layer1/CGO.cpp:1539), and hands back the resulting CGO_DRAW_ARRAYS float blocks as raw PyBytes using the primitive that already exists for pse_binary_dump: PConvFloatArrayToPyList(f, l, dump_binary=true)PyBytes_FromStringAndSize (packages/engine/layer1/PConv.cpp:971-977). That block is literally a three.js BufferGeometry.
  • Visual parity is achievable for the interactive (GL) viewport and NOT for ray tracing. See §8.

1. The two geometry pipelines inside PyMOL

PyMOL has three renderers behind one Rep::render(RenderInfo*) virtual (packages/engine/layer1/Rep.h:198): The critical structural fact, verified in packages/engine/layer2/RepSphere.cpp:149-207: renderCGO is generated lazily inside render() and only when G->HaveGUI && G->ValidContext (line 164). The build-time artifact (primitiveCGO) is generated in RepSphereNew (packages/engine/layer2/RepSphere.cpp:523-582) with no GL involvement at all. Same shape for cartoon: I->preshader is built in RepCartoonNew (packages/engine/layer2/RepCartoon.cpp:4287-4300) with no GL, then consumed and destroyed by RepCartoonCGOGenerate() at first GL render (packages/engine/layer2/RepCartoon.cpp:263-274, dispose at :240). Note the salvage path RepCartoon::disposePreshaderCGO() (packages/engine/layer2/RepCartoon.cpp:83-89): if ray is null the preshader is moved into ray rather than freed — so in a ray-capable build the CPU cartoon geometry survives GL upload.

Where the CPU copy is lost

OptimizeVertsToVBONotIndexed() builds an interleaved std::vector<float> vertexValsVec(tot) on the stack (packages/engine/layer1/CGO.cpp:2947-2948), fills it via CGOProcessCGOtoArrays() (:2968), uploads with SetVertexBufferData() (:2998), and lets the vector die at scope exit. packages/engine/layer1/CGO.h:183-186 states this explicitly: “the geometry data is not kept in the CGO object for VBO objects (only on the card)” — which is why CGO_BOUNDING_BOX exists at all. Conclusion: do not try to read back VBOs. Read the pre-VBO CPU CGO.

The point in the pipeline where CPU geometry is complete

SceneUpdate(G, force) (packages/engine/layer1/Scene.cpp:4664) drives obj->update() for every object (packages/engine/layer1/Scene.cpp:4757-4760 single-threaded branch, :4745-4751 threaded branch), which reaches Rep::update()Rep::rebuild()fNew(cs, state) (packages/engine/layer1/Rep.cpp:42-57, 65-101). After SceneUpdate returns, every Rep’s CPU geometry is final and no GL context was required. cmd.rebuild() (packages/engine/modules/pymol/viewing.py:1837-1866_cmd.rebuild) and cmd.refresh() (packages/engine/modules/pymol/viewing.py:1750-1772) are the Python triggers.

2. Per-Rep geometry inventory (what each Rep actually produces)

Enumerated from enum cRep_t (packages/engine/layer1/Rep.h:48-74). Reps live in CoordSet::Rep[cRepCnt] with an Active[cRepCnt] flag array (packages/engine/layer2/CoordSet.h:107-108).

cRepSurface (2) — packages/engine/layer2/RepSurface.cpp:59-101

The richest and easiest target. All arrays are std::vector members, CPU-resident, never freed after GL upload: T is produced by TrianglePointsToSurface() (packages/engine/layer2/RepSurface.cpp:4035-4036, declared packages/engine/layer0/Triangle.h:25-27). Colors VC/VA/RC are (re)computed in RepSurface::recolor() (packages/engine/layer2/RepSurface.cpp:2363). Per-triangle culling uses visibility_test(proximity, Vis, &T[3i]) (packages/engine/layer2/RepSurface.cpp:209-216) — the client MUST replicate this or it will draw hidden surface patches. Direct three.js mapping: Vposition, VNnormal, VC+VAcolor (RGBA), T → index buffer, VAO → custom attribute, AT → picking id attribute. Nothing to recompute.

cRepSphere (1) — packages/engine/layer2/RepSphere.h:29-40

primitiveCGO (:38) holds CGO_SPHERE ops (center xyz + radius) interleaved with CGO_COLOR, CGO_ALPHA, CGO_PICK_COLOR (packages/engine/layer2/RepSphere.cpp:290-313). Built GL-free at packages/engine/layer2/RepSphere.cpp:523-582. spheroidCGO (:39) is the anisotropic-spheroid variant built by RepSphereGeneratespheroidCGO() (packages/engine/layer2/RepSphere.cpp:366) which emits real triangles. GL path forks on sphere_mode (packages/engine/layer2/RepSphereGenerate.cpp:24-102):
  • mode 9 (default) → GLSL impostor quads, CGOOptimizeSpheresToVBONonIndexed (packages/engine/layer1/CGO.cpp:4357)
  • mode 0 / cube / tetrahedron → real triangles via CGOSimplify(primitiveCGO, 0, sphere_quality) (packages/engine/layer2/RepSphereGenerate.cpp:36)
  • modes 1,2,3,6,7,8 → point sprites, CGOConvertSpheresToPoints (packages/engine/layer2/RepSphereGenerate.cpp:75)

cRepCyl (0, sticks) — packages/engine/layer2/RepCylBond.cpp:39-49

primitiveCGO (:47) built GL-free (packages/engine/layer2/RepCylBond.cpp:635 alloc, :903 CGOStop). Contains CGO_SHADER_CYLINDER / CGO_SHADER_CYLINDER_WITH_2ND_COLOR / CGO_SPHERE (for zero-order bond dots, :798) plus color/alpha/pickcolor. RepCylinder() helper documented at :51-68.

cRepCartoon (5) — packages/engine/layer2/RepCartoon.cpp:65-92

preshader (:77) is the CPU CGO. Built by GenerateRepCartoonCGO() (packages/engine/layer2/RepCartoon.cpp:4287-4290), then immediately normalized with CGOCombineBeginEnd() (packages/engine/layer2/RepCartoon.cpp:4292-4294) — meaning cartoon geometry is already in CGO_DRAW_ARRAYS interleaved-array form before anything GL happens. The actual extrusion emits GL_TRIANGLE_STRIP / GL_TRIANGLE_FAN / GL_LINE_STRIP blocks in packages/engine/layer1/Extrude.cpp (:839, :932, :1036, :1129, :1363, :1450, :1483, :1569, :1631, :1671, :1793, :1912, :1987, :2047, :2128, :2153) plus cgo::draw::shadercylinder2ndcolor for nucleic-acid-as-cylinders (packages/engine/layer1/Extrude.cpp:1219), plus CGOSphere for ring centers (packages/engine/layer2/RepCartoon.cpp:1295) and GL_TRIANGLES for rings (:1346).

cRepRibbon (6) — packages/engine/layer2/RepRibbon.cpp:37-50: primitiveCGO (:48), CPU.

cRepLine (7, wire) — packages/engine/layer2/RepWireBond.cpp:34-45: primitiveCGO (:43), CPU. CGO_LINE/CGO_SPLITLINE.

cRepNonbonded (11) — packages/engine/layer2/RepNonbonded.cpp:33-44: primitiveCGO (:41), CPU.

cRepNonbondedSphere (4) — packages/engine/layer2/RepNonbondedSphere.cpp:35-44: primitiveCGO (:43), CPU.

cRepEllipsoid (19) — packages/engine/layer2/RepEllipsoid.cpp:33-44: ray (:41) and std (:42) CGOs. CGO_ELLIPSOIDCGOSimpleEllipsoid in CGOSimplify (packages/engine/layer1/CGO.cpp:4535).

cRepMesh (8) — packages/engine/layer2/RepMesh.cpp:39-63: raw pymol::vla<float> V (:51), N strip lengths (:49), NTot (:50), VC colors (:52), Dot/NDot (:53-54). CPU, permanent.

cRepDot (9) — packages/engine/layer2/RepDot.h:26-45: V, VC, A (area), VN, T, F, Atom raw float/int arrays (:34-42). CPU, permanent.

cRepDash (10) — packages/engine/layer2/RepDistDash.cpp:40-55: raw float* V (:48), N (:49). CPU.

cRepAngle (17) — packages/engine/layer2/RepAngle.cpp:36-50: pymol::vla<float> V (:44). CPU.

cRepDihedral (18) — packages/engine/layer2/RepDihedral.cpp:35-48: float* V (:43). CPU.

cRepLabel (3) — packages/engine/layer2/RepLabel.cpp:90-104: labelV vector + lexer indices L (:98-99).

Geometry is texture-atlas quads, not mesh. Glyph bitmaps are reachable CPU-side via CharacterGetPixmapBuffer() (packages/engine/layer1/Character.cpp:122-130) and metrics via CharacterGetGeometry / CharacterGetWidth / CharacterGetHeight / CharacterGetAdvance (packages/engine/layer1/Character.h:80-85). Ops are CGO_DRAW_LABEL (packages/engine/layer1/CGO.h:224-225) and CGO_DRAW_LABELS (:228).

cRepDistLabel — packages/engine/layer2/RepDistLabel.cpp:41-56: same texture-quad story.

cRepCGO (13) — user CGOs. ObjectCGOState::origCGO (packages/engine/layer2/ObjectCGO.h:25) is CPU and

already round-trips to Python — see §3.

cRepCallback (14) — arbitrary Python glDraw* callbacks. Not extractable, by definition.

cRepVolume (20) / cRepSlice (16) — 3D-texture ray-marching (packages/engine/data/shaders/volume.fs),

not triangle geometry. Out of scope for a mesh feed.

cRepCell (12) / cRepExtent (15) — trivial line boxes.


3. CGO — the existing serialization format (this is the wire format, already)

class CGO (packages/engine/layer1/CGO.h:765-773) is a float* op array of length c. Opcodes are #defines at packages/engine/layer1/CGO.h:82-270, each with a fixed _SZ payload length. The Python-side mirror of the opcode table already exists at packages/engine/modules/pymol/cgo.py:21-79. Key opcodes for a geometry feed:

CGO_DRAW_ARRAYS payload layout — the money shot

struct cgo::draw::arrays (packages/engine/layer1/CGO.h:338-355) carries {mode, arraybits, narrays, nverts} plus a heap block. CGOCombineBeginEnd() lays that block out at packages/engine/layer1/CGO.cpp:1645-1672:
Sizes: VERTEX_POS_SIZE 3 / VERTEX_COLOR_SIZE 4 (packages/engine/layer0/ShaderMgr.h:430-431), VERTEX_NORMAL_SIZE 3 (packages/engine/layer1/CGO.cpp:55), VERTEX_PICKCOLOR_SIZE = 1 + 2 and VERTEX_ACCESSIBILITY_SIZE 1 (packages/engine/layer1/CGO.cpp:60-65). Array bit flags at packages/engine/layer1/CGO.h:272-277. This is a three.js BufferGeometry with zero transformation. mode is a raw GL enum (GL_TRIANGLES, GL_TRIANGLE_STRIP, GL_TRIANGLE_FAN, GL_LINES, GL_LINE_STRIP, GL_POINTS — enumerated in packages/engine/layer1/CGO.h:68-72 and mirrored in packages/engine/modules/pymol/cgo.py:22-27).

The two CPU tessellators (no GL required)

  • CGOSimplify(const CGO*, est, sphere_quality, stick_round_nub)packages/engine/layer1/CGO.cpp:4444. Converts every analytic primitive into triangles: CGO_SPHERECGOSimpleSphere (:4533) using G->Sphere->Sphere[sphere_quality]; CGO_SHADER_CYLINDER / CGO_CYLINDER / CGO_SAUSAGE / CGO_CUSTOM_CYLINDER / CGO_CUSTOM_CYLINDER_ALPHACGOSimpleCylinder (:4474-4530); CGO_CONECGOSimpleCone (:4511); CGO_ELLIPSOIDCGOSimpleEllipsoid (:4536); CGO_QUADRICCGOSimpleQuadric (:4539). It also folds BEGIN/…/END blocks into CGO_DRAW_ARRAYS (:4550-4620).
  • CGOCombineBeginEnd(const CGO*, est, do_not_split_lines)packages/engine/layer1/CGO.cpp:1539. BEGIN/VERTEX/END → CGO_DRAW_ARRAYS only (no primitive tessellation).
Both are pure CPU. Both are already called from Rep code today.

CGO ↔ Python already exists (for ObjectCGO only)

  • CGOAsPyList(CGO*) (packages/engine/layer1/CGO.cpp:289-297) → [len, flat_float_list], built by CGOArrayAsPyList (packages/engine/layer1/CGO.cpp:241-287). It explicitly handles CGO_DRAW_ARRAYS (:255-265), flattening {mode, arraybits, narrays, nverts} + the data block. So the serializer for the exact format we want already exists in the tree.
  • CGONewFromPyList() (packages/engine/layer1/CGO.cpp:392) is the inverse.
  • Reached from Python only via session save of ObjectCGO (packages/engine/layer2/ObjectCGO.cpp:43, 151, packages/engine/layer3/Executive.cpp:5407), GadgetSet (packages/engine/layer2/GadgetSet.cpp:191, 197), and CoordSet::SculptCGO (packages/engine/layer2/CoordSet.cpp:392-394).
  • Import side: cmd.load_cgo(list_of_floats, name, state) (packages/engine/modules/pymol/importing.py:307-324) → ObjectCGOFromFloatArray (packages/engine/layer2/ObjectCGO.h:47) / CGOFromFloatArray (packages/engine/layer1/CGO.h:1109).
cmd.get_session() does NOT contain molecular rep geometry. CoordSetAsPyList (packages/engine/layer2/CoordSet.cpp:364-416) writes coords, atom index maps, settings, symmetry, and SculptCGO — no Rep[]. Verified by reading the whole function.

Binary blob primitive already exists

PConvFloatArrayToPyList(const float*, int, bool dump_binary) returns PyBytes_FromStringAndSize(reinterpret_cast<const char*>(f), l * sizeof(float)) when dump_binary is true (packages/engine/layer1/PConv.cpp:971-977). Used today by pse_binary_dump (packages/engine/layer2/CoordSet.cpp:371-372, 377). This is exactly the zero-copy-ish primitive a binary geometry accessor needs — no new serialization machinery to invent. Zero-copy numpy precedent: CoordSetAsNumPyArray(cs, copy) (packages/engine/layer2/CoordSet.cpp:326-361) — with copy == 0 it returns PyArray_SimpleNewFromData(2, dims, typenum, cs->Coord.data()) (:357), a numpy view onto live C++ memory. Exposed as cmd.get_coordset(name, state, copy) (packages/engine/layer4/Cmd.cpp:2033-2056, table entry packages/engine/layer4/Cmd.cpp:6457).

4. Complete inventory of existing Python-exposed export paths

Registered in savefunctions at packages/engine/modules/pymol/exporting.py:988-1020. Shared cost model for all six geometry exporters. Every one calls SceneRay(G, 0, 0, mode, …) (packages/engine/layer1/SceneRay.cpp:88), which:
  1. SceneUpdate(G, false) (:152) — rebuild all reps if dirty,
  2. allocates a fresh CRay (:213) and re-runs obj->render(&info) for every object with info.ray set (:284-323), re-emitting every primitive from scratch,
  3. RayExpandPrimitives() (packages/engine/layer1/Ray.cpp:561) — allocates vertex/normal/radius VLAs sized to the full primitive count,
  4. RayTransformFirst() (packages/engine/layer1/Ray.cpp:822) — transforms all vertices by ModelView (skipped only when geometry_export_mode == 1, packages/engine/layer1/Ray.cpp:876-881),
  5. sprintf everything into a char* VLA,
  6. RayFree(ray) (:492) — throws the whole thing away.
There is no incremental mode, no dirty-tracking, no caching. A CPrimitive is 172 bytes (packages/engine/layer1/Basis.h:68-83) and the header comment notes ~6.5M primitives/GB. Every call rebuilds everything.

5. Which existing path is closest to usable, and what it costs

5a. Winner for surfaces (today, no C++ changes): the surface cache

cmd.cache('enable') sets cache_mode=2 (packages/engine/modules/pymol/exporting.py:87-88). Then, in RepSurfaceNew, after SurfaceJobRun() succeeds and cache_mode > 1, PyMOL calls SurfaceJobResultAsTuple() and PCacheSet() (packages/engine/layer2/RepSurface.cpp:4566-4574). SurfaceJobResultAsTuple (packages/engine/layer2/RepSurface.cpp:3217-3229) builds:
PCacheSet (packages/engine/layer1/P.cpp:1349-1376) stores it as entry[3] on a list [size, hash_list, input_tuple, output_tuple, access_count, timestamp] appended to pymol._cache by cmd._cache_set (packages/engine/modules/pymol/internal.py:101-125). It is directly readable:
Fidelity: exact surface mesh, exact normals. Missing: VC colors, VA alpha, Vis visibility, VAO occlusion, AT atom mapping — all of which live on the RepSurface and are not part of the cached SurfaceJob result. Missing colors is fatal for a viewer, so this is a prototype-grade path only. Cost: PConvToPyObject(std::vector<float>) (packages/engine/layer1/PConv.h:251-259) builds a PyList of boxed PyFloats — one object per float. A 500k-vertex surface = 3M PyFloats ≈ 70+ MB and hundreds of ms just to materialize, plus the same again to JSON/msgpack it. Only acceptable once per surface build, never per frame.

5b. Winner for everything else (today): cmd.get_vrml(2)

Highest-fidelity general export. Still one full SceneRay per call, ASCII, camera-space, cones dropped, labels dropped, ellipsoids dropped.

5c. Per-frame cost verdict

None of the existing paths can run per frame. Rough shape of the cost for a modest 3k-atom cartoon+sticks scene: Conclusion for the architecture: geometry must NOT be re-derived per frame at all. The client should hold the mesh and only receive (a) camera/view updates via cmd.get_view() (packages/engine/modules/pymol/viewing.py:634-679, 18 floats: 3x3 rotation, camera-space origin, model-space origin, front/back clip, ortho-flag/FOV) and (b) invalidation events when a rep is rebuilt.

6. The new C++/Python accessor

This section is the design that became _cmd.web_get_rep_geometry (packages/engine/layer4/Cmd.cpp:6471, implementation packages/engine/layer4/CmdWebGeometry.cpp, wrapped in /* tenmol web client -- BEGIN/END */ sentinels).

6a. Design

One new _cmd method, web_get_rep_geometry(G, object_name, state, rep_id, flags), registered in the method table alongside the existing get_* entries (packages/engine/layer4/Cmd.cpp:6466-6478). It follows the shape of CmdGetCoordSetAsNumPy (packages/engine/layer4/Cmd.cpp:2033-2056): API_SETUP_ARGSAPIEnterBlockedExecutiveGetCoordSet (packages/engine/layer3/Executive.h:869) → build result → APIExitBlocked. Resolution path (all already public):
Preconditions: call SceneUpdate(G, true) (packages/engine/layer1/Scene.cpp:4664) first, or require the caller to have called cmd.rebuild() — otherwise Rep[] may be stale or null.

6b. Return payload

A dict per rep, with PyBytes blobs, not lists:
For CGO-backed reps:

6c. Implementation, entirely from existing functions

To keep spheres/sticks as GPU impostors instead of tessellated triangles (much smaller payload, matches PyMOL’s own default sphere_mode 9 / render_as_cylinders), skip CGOSimplify and instead walk the CGO_SPHERE / CGO_SHADER_CYLINDER / CGO_SHADER_CYLINDER_WITH_2ND_COLOR ops directly and pack them into instance buffers. That mirrors what CGOOptimizeSpheresToVBONonIndexed (packages/engine/layer1/CGO.cpp:4357-4394) does for GL.

6d. Bulk / streaming variant

Add _cmd.get_scene_geometry(G, flags) that iterates G->Scene->Obj the same way SceneRay does (packages/engine/layer1/SceneRay.cpp:284-323) and returns a list of the per-rep dicts above, plus per-object transforms from cmd.get_object_matrix (packages/engine/layer4/Cmd.cpp:6480) — required because ObjectMolecule::render applies ObjectStatePushAndApplyMatrix when matrix_mode != 0 (packages/engine/layer2/ObjectMolecule.cpp:11265-11269).

6e. Invalidation events

The bridge must know when to re-pull. Rep::invalidate(cRepInv_t) (packages/engine/layer1/Rep.h:199) and the cRepInv_t ladder (packages/engine/layer1/Rep.h:133-184) are the natural hook: cRepInvColor(15) → re-send colors only; cRepInvRep(35)/cRepInvAll(100) → re-send everything. There is no existing Python callback for rep invalidation — I grepped and found none. This must be added (a PYOBJECT_CALLMETHOD into G->P_inst->cmd, following the pattern of call_raw_image_callback at packages/engine/layer1/Scene.cpp:4020-4051).

6f. What it cost

  • Surface accessor: memcpy of 8 existing vectors.
  • CGO accessor: CGOSimplify + iterate + PyBytes, a variant of the existing CGOArrayAsPyList (packages/engine/layer1/CGO.cpp:241-287).
  • Invalidation: no callback was added. Rep::update() runs on worker threads when async_builds is on (packages/engine/layer1/Scene.cpp:4740-4757), so a callback would have had to avoid touching Python from a non-GIL thread. Instead the four change counters of _cmd.web_get_versions are polled — see docs/cmd-api-rpc.md §8.5 and packages/bridge/tenmol_bridge/state/repversions.py.

7. Shader parity assets already in the repo

packages/engine/data/shaders/ contains PyMOL’s GLSL, and the preprocessor already has WebGL/ES2 code paths: m_shaderPreprocessor.setVar("PURE_OPENGL_ES_2"/"PYMOL_WEBGL"/"PYMOL_WEBGL_IOS", true) under #ifdef PURE_OPENGL_ES_2 (packages/engine/layer0/ShaderMgr.cpp:604-607), with packages/engine/data/shaders/webgl_header.vs and packages/engine/data/shaders/webgl_header.fs supplying the uniform block (g_NormalMatrix, g_ModelViewMatrix, g_ProjectionMatrix, g_Fog_end, g_Fog_scale). Programs registered (packages/engine/layer0/ShaderMgr.cpp:617-658): bg, label, default, surface, line, screen, connector, bezier, cylinder, sphere, ramp, oit, trilines. Shared includes: compute_color_for_light.fs (101 lines), compute_fog_color.fs (52), anaglyph_header.fs (161), call_compute_color_for_light.fs. sphere.fs (94 lines) is a genuine ray-sphere impostor writing gl_FragDepth — requires GL_EXT_frag_depth on WebGL1, native on WebGL2 (packages/engine/data/shaders/sphere.fs:1-3, 55-60). cylinder.fs is 187 lines of impostor math. These are directly portable with matched uniform names, and are what packages/viewport/src/modeG/materials/ and packages/viewport/src/shaders/ are derived from. ~1840 lines of GLSL total across all shaders — this is the single biggest lever for visual parity and it is copy-adaptable, not reinventable.

8. Verdict

Can a browser client reach visual parity with PyMOL by drawing PyMOL’s own geometry? For the interactive OpenGL viewport: yes. The mesh data is exact (it is PyMOL’s data), the shaders are portable, and the camera model is fully specified by cmd.get_view(). What remains is a long tail of small mismatches, enumerated below. For ray-traced output: no. cmd.ray output is fetched as pixels from the backend instead (Mode P), never reproduced client-side.

Where it will definitively NOT match

  1. Ray tracing (cmd.ray) — irreproducible in three.js. RayRender (packages/engine/layer1/Ray.cpp, 7827 lines) is a CPU ray tracer with real shadows, multi-light specular, interior colors, and ray_trace_mode cel-shading/outlines (packages/engine/layer1/Ray.cpp:5570-5607, 6472-6474). ray_trace_mode exists only in the ray tracer — grep of packages/engine/layer1/SceneRay.cpp and packages/engine/layer1/SceneRender.cpp found zero references to it in the GL path. There is no path to parity here. The web client must call cmd.ray() + cmd.png() server-side and display the resulting bitmap. This is a mode switch, not a rendering feature — and the UX must make that explicit.
  2. Order-independent transparency (transparency_mode 3). PyMOL binds an OIT accumulation framebuffer with multiple draw buffers and a dedicated oit shader (packages/engine/layer1/SceneRender.cpp:939-995, packages/engine/data/shaders/oit.vs/.fs, packages/engine/layer0/PostProcess.cpp:60, 86, TM3_IS_ONEBUF handling at packages/engine/layer1/SceneRender.cpp:979, 1101, 1466). WebGL2 has no GL_EXT_draw_buffers-style MRT parity issues but does lack per-sample control PyMOL relies on; an approximate WBOIT is achievable but will not be pixel-identical.
  3. CPU depth-sorted alpha triangles (transparency_mode != 3). The other transparency path sorts triangles on the CPU per frame: CGOOptimizeToVBOIndexedWithColorEmbedTransparentInfo embeds z_value, ix, and sort_mem arrays into the CGO (packages/engine/layer1/CGO.cpp:3395-3405), and RepSurface::render allocates t_buf/z_value/ix sized NT and re-sorts every frame (packages/engine/layer2/RepSurface.cpp:1969-1984). Reproducing this in JS means per-frame triangle sorting of up to 10^6 triangles in the browser — that will not hold 60 fps. Either accept sort artifacts or ship the sorted index buffer from Python each frame (which reintroduces per-frame cost).
  4. Stereo. PyMOL implements 9 stereo modes: quadbuffer, crosseye, walleye, geowall, sidebyside, stencil-by-row/column/checkerboard/custom, anaglyph, openvr (packages/engine/layer1/SceneRay.cpp:154-175, 515-713). Quad-buffer stereo and stencil-parity stereo are impossible in a browser. Anaglyph and side-by-side are reproducible. OpenVR is out.
  5. Post-process antialiasing. antialias_shader selects FXAA (1 stage) or SMAA (3 stages) (packages/engine/layer1/SceneRender.cpp:613-627, 1791). Reproducible with effort, but PyMOL’s exact SMAA parameters must be ported or edges will differ visibly at high zoom.
  6. Labels. cRepLabel is texture-atlas quads driven by CGO_DRAW_LABEL / CGO_DRAW_LABELS (packages/engine/layer1/CGO.h:224-228) and a texture-size heuristic (InvalidateShaderCGOIfTextureNeedsUpdate, packages/engine/layer2/RepLabel.cpp:117, MAX_LABEL_TEXTURE_SIZE 256, :114). Glyphs are CPU-reachable (packages/engine/layer1/Character.cpp:122) but the atlas must be rebuilt client-side. Font rasterization will differ from PyMOL’s FreeType output at the pixel level. Expect near-miss, not match.
  7. cRepCallback (14). Objects created by cmd.load_callback execute arbitrary Python that issues raw OpenGL. packages/engine/layer2/ObjectCallback.cpp exists for exactly this. There is no geometry to extract — by construction. Any plugin using it is unsupported in the web client.
  8. cRepVolume (20) and cRepSlice (16). 3D-texture ray marching (packages/engine/data/shaders/volume.fs, 47 lines). Not mesh geometry. Requires a separate 3D-texture upload path (cmd.get_volume_field exists, packages/engine/layer4/Cmd.cpp:6510, packages/engine/layer4/Cmd.cpp:727FieldAsNumPyArray) and a hand-ported volume shader. Treat as a separate epic.
  9. Ambient occlusion. VAO per-vertex occlusion is computed on the surface (packages/engine/layer2/RepSurface.cpp:2780-2800) and shipped as a vertex attribute — that part transfers fine. But ambient_occlusion_mode also has a smoothing pass with its own map (ambient_occlusion_map, packages/engine/layer2/RepSurface.cpp:2368) whose result is baked into VAO, so as long as the client reads VAO after rebuild, this one actually matches.
  10. Grid mode. GridMode::ByObject / ByObjectStates / ByObjectByState (packages/engine/layer1/SceneRay.cpp:179-211, 306-320) splits the viewport into N sub-viewports with adjusted aspect ratios. Reproducible with N three.js viewports, but the layout math (GridUpdate, grid.asp_adjust) must be ported exactly or panels will misalign.
  11. Precision. All PyMOL geometry is float32 and all colors are float32 RGB in [0,1] (CLIP_COLOR_VALUE / CONVERT_COLOR_VALUE, packages/engine/layer1/CGO.h:54-55). Optional byte-packed normals/colors are gated on cgo_shader_ub_color / cgo_shader_ub_normal (packages/engine/layer1/CGO.cpp:2939-2961, CLIP_NORMAL_VALUE at packages/engine/layer1/CGO.h:58-61). If the accessor emits float32 and the client uses float32, this is exact.
  12. Picking. PyMOL picks by rendering a pick-color buffer (CGORenderPicking, packages/engine/layer2/RepSphere.cpp:120, packages/engine/layer1/ScenePicking.cpp). The CGO_PICK_COLOR payload (atom index, bond index, packages/engine/layer1/CGO.h:141-142) is in the extracted arrays, so client-side GPU picking is achievable — but the encoding (Picking.h, cPickableAtom = -1cPickableThrough = -5, packages/engine/modules/pymol/cgo.py:73-77) must be replicated bit-for-bit or selections will land on the wrong atoms.

9. Questions this map left open, and how they were closed

  • No Python hook fires on Rep rebuild/invalidation. Confirmed by grep over packages/engine/layer1/P.cpp, packages/engine/layer4/Cmd.cpp and packages/engine/modules/pymol/*.py. None was added; §6f explains what replaced it.
  • Is RepSurface::AT populated in all surface modes, or only with pick_surface? AT is declared at packages/engine/layer2/RepSurface.cpp:83; not every write site was traced here. docs/spikes/picking.md answered the practical question instead — surface picking runs on the backend against a real GL pick pass (15/15 clicks resolved), so client-side reliance on AT never became load-bearing.
  • The byte layout of the pick-color sub-block inside CGO_DRAW_ARRAYS (VERTEX_PICKCOLOR_RGBA_SIZE = 1 float holding 4 packed bytes, plus 2 index floats, packages/engine/layer1/CGO.cpp:60-63). The offset arithmetic at packages/engine/layer1/CGO.cpp:1665 (pickColorVals = nxtVals + VERTEX_PICKCOLOR_RGBA_SIZE * nverts) implies the RGBA sub-array precedes the index sub-array in the same block. Verified at runtime by docs/spikes/geometry-accessor.md, which is what packages/bridge/tenmol_bridge/render/modeg.py::_deinterleave_i32 depends on.
  • _PYMOL_NO_RAY guards SceneRay entirely (packages/engine/layer1/SceneRay.cpp:94-96). No setup.py flag sets it, so ray export is compiled in for this fork’s builds.
  • Whether pymol._cache surface entries survive cmd.delete/rebuild cycles cleanly enough to be a reliable feed — _cache_purge (packages/engine/modules/pymol/internal.py:49-78) evicts by size and access time. Moot: §6’s accessor reads Rep[] directly rather than the cache.