The 3D viewer hands developers a live handle on its Three.js scene. With it you can read and change the scene the customer is looking at, add your own materials and shaders, drive animations, and insert full-screen postprocessing passes — without forking the plugin.
This page documents the frontend viewer. The admin preview is a separate view and is not covered by these hooks.
Getting the API
The viewer builds its scene asynchronously — the model has to download first — so there is no object to grab on page load. Instead, the viewer announces itself when the scene is ready:
wp.hooks.addAction( 'PC.fe.viewer.runtime.ready', 'my-plugin/3d', function ( viewer, three, api ) { const THREE = api.getTHREE(); const scene = api.getScene(); const camera = api.getCamera(); // … your code … api.requestRender();} );
The same object is also parked on window.PC.fe.threeApi.viewer, but only from this point
onwards. Always go through the hook; polling the global is a race you will lose on slow
connections.
PC.fe.viewer.runtime.ready fires again after the browser loses and restores the WebGL
context, which rebuilds the scene from scratch. A runtime:dispose event always comes first, so
re-subscribing on every runtime.ready is correct — and necessary, since every object you were
holding is gone by then.
Loading your script
Depend on wp-hooks and nothing else:
add_action( 'wp_enqueue_scripts', function () { wp_enqueue_script( 'my-plugin-3d', plugin_dir_url( __FILE__ ) . 'js/my-3d-customisations.js', array( 'wp-hooks' ), '1.0.0', true );} );
It is tempting to depend on the viewer's own handle, mkl_pc/fe_3d_viewer, but that handle is
only registered on products that are actually 3D configurators, so depending on it breaks your
script everywhere else. wp-hooks always loads before the viewer, which is all you need.
API reference
| Member | Returns |
|---|---|
apiVersion | Shape version of this object. 4 at the time of writing. |
THREE / getTHREE() | The viewer's Three.js instance. |
getScene() | The THREE.Scene. |
getCamera() | The active camera. |
getControls() | The OrbitControls instance. |
getRenderer() | The THREE.WebGLRenderer. |
getModelRoot() | The THREE.Group every loaded model is mounted under. |
getMaterialRegistry() | Map of material name → THREE.Material, the same one the choice actions write to. |
getMaterial( name ) | One material from that registry, or null. |
getTextureLoader() | The viewer's shared THREE.TextureLoader. |
findObjectById( id ) | An object by its configured id. |
findObjectByCompositeId( id ) | An object by its source:name composite id — the first copy. |
findObjectsByCompositeId( id ) | Every match for that id, one per copy of the model. Version 3. |
getSceneForObject3dId( id ) | The scene root of a loaded 3D object — the first copy — or null. |
placement | The placement manager: request( key, spec ), release( key ), refresh(), getAnchors( object ), isParked( object ), utils. See Placing objects on anchors. Version 4. |
getScenesForObject3dId( id ) | Every mounted copy of that 3D object, as an array. Version 3. |
ensureObject3dLoaded( id ) | Promise resolving to that scene root, loading it if lazy. |
getObject3dAnimations( id ) | The THREE.AnimationClip[] from that model's glTF. |
getActiveAngle() | The active angle model, or null. |
on( event, cb ) / off( event, cb ) | Subscribe to the events below. |
requestRender() | Ask for a frame. See On-demand rendering. |
pauseRenderLoop() / resumeRenderLoop() | Stop and restart the loop. |
Use apiVersion for information only. To find out whether a particular member exists, check for
the member:
if ( typeof api.getMaterialRegistry === 'function' ) { /* … */ }
On-demand rendering
The viewer does not redraw every frame. It draws only when something has changed, which is what lets a 3D product page sit open on a laptop without draining the battery.
The consequence for you: anything you change outside the viewer's own code — a uniform, a mixer, a canvas texture — reaches the screen only if you ask for it.
material.uniforms.uTime.value = elapsed;api.requestRender();
The frame event still fires on every animation frame whether or not a redraw happens, so it
remains the right place to advance time-based work.
When a model appears more than once
A model placed on a layout gets one copy per anchor of the layout's current variant. The first anchor gets the model itself and each of the others gets a copy: one leg model on the four attachment points of a table top, say. Copies are rebuilt when the variant changes, so their number can change too. Write code with that in mind:
- To act on every copy, use the plural members. A hotspot bound to
12:Footthat should read the same on all four legs, or a fold animation that should move every leg, loops overgetScenesForObject3dId()orfindObjectsByCompositeId(). - To act on one copy, take it from that same array — the one nearest the camera, or the one under a particular attachment point. Each copy is its own object, so a pin, a mixer or a material override can be attached to just that one.
- The singular members keep their meaning: they return the first copy. Code written against them keeps working unchanged, but it will only ever see one leg.
Copies share geometry and materials with the original and read its visible and material
live, so hiding or recolouring the original shows on every copy. A copy's parent is the anchor
it sits on, which is how you tell copies apart. placement:changed (below) fires whenever an
object is placed, moved or put back, so that is the moment to look things up again.
Placement also means an object's parent is no longer a reliable guide to which model it came from: a model can hang off another model's anchor. Composite ids still resolve by the model an object was authored in. Use them rather than walking the scene graph.
On hosts older than version 3 the plural members are missing, so check for them:
const feet = typeof api.findObjectsByCompositeId === 'function' ? api.findObjectsByCompositeId( '12:Foot' ) : [ api.findObjectByCompositeId( '12:Foot' ) ].filter( Boolean );
Events
Subscribe with api.on( name, callback ).
| Event | Payload |
|---|---|
runtime:ready | { three } |
frame | { deltaSeconds } — every animation frame, redraw or not |
angle:changed | { previous, current } — angle models |
object3d:loaded | { object3dId, scene, animations } — once per model file, with its first copy |
placement:changed | { target, anchors, copies, parked? } — an object was placed on anchors, or put back (anchors empty). parked is true when a model on a layout was hidden for lack of anchors. Version 3. |
material:applied | See below |
context:lost / context:restored | {} |
runtime:dispose | {} — release everything you are holding |
Every event is also mirrored as a WordPress action, if you would rather not hold a reference to the API:
| Action | Arguments |
|---|---|
PC.fe.viewer.runtime.ready | viewer, three, api |
PC.fe.viewer.frame | viewer, deltaSeconds, api |
PC.fe.viewer.angle.changed | viewer, previous, current, api |
PC.fe.viewer.object3d.loaded | viewer, object3dId, scene, animations, api |
PC.fe.viewer.material.applied | viewer, payload, api |
PC.fe.viewer.placement.changed | viewer, target, anchors, copies, api |
PC.fe.viewer.layout.variant_changed | viewer, layoutId, variantId, api — fired by 3D Premium |
PC.fe.viewer.placement.ready | viewer, api — the placement manager exists and no model has loaded yet |
PC.fe.viewer.context.lost / .restored | viewer, api |
PC.fe.viewer.runtime.dispose | viewer, three, api |
PC.fe.viewer.webgl.unavailable | viewer, error |
PC.fe.viewer.runtime.event | viewer, eventName, payload, api — catch-all |
Custom materials and shaders
You can replace any material in the scene from runtime.ready:
wp.hooks.addAction( 'PC.fe.viewer.runtime.ready', 'my-plugin/3d', function ( viewer, three, api ) { const THREE = api.getTHREE(); const body = api.getMaterial( 'Body' ); if ( ! body ) return; // Patch the stock material rather than replacing it, so it keeps the // scene's lighting, environment map and shadows. body.onBeforeCompile = function ( shader ) { shader.uniforms.uTime = { value: 0 }; shader.fragmentShader = 'uniform float uTime;\n' + shader.fragmentShader; // … your modifications … body.userData.shader = shader; }; body.needsUpdate = true; api.on( 'frame', function ( { deltaSeconds } ) { const shader = body.userData.shader; if ( ! shader ) return; shader.uniforms.uTime.value += deltaSeconds; api.requestRender(); } );} );
Keeping your material when the customer picks a choice
This is the part that catches people out. Choice actions write straight onto the materials in
the registry, and the Apply material action reassigns mesh.material outright. The moment a
customer clicks a choice that targets the same material or mesh, your work is overwritten.
The material:applied event tells you when that happened and exactly what moved:
| Key | Meaning |
|---|---|
phase | 'apply' when a choice became active, 'restore' when it was deselected |
actionType | material_property, material_color_registry, material_texture, apply_material or material_variant |
action | The raw action as configured in the admin |
material | The affected THREE.Material, or null |
materialName | Its name in the registry, or '' |
meshes | Meshes whose .material reference was reassigned. Empty unless actionType is apply_material |
variantRoot | For material_variant, the glTF root the variant was selected on |
targetObject / targetScene | The choice's 3D target |
choice / layer | The Backbone models that caused it |
So a shader that has to survive the configurator looks like this:
let queued = false;// One choice can carry several actions, and each reports separately.// Coalescing onto a frame reapplies the shader once per interaction.function scheduleReapply() { if ( queued ) return; queued = true; requestAnimationFrame( function () { queued = false; applyMyShader(); api.requestRender(); } );}api.on( 'material:applied', function ( payload ) { if ( payload.materialName !== 'Body' ) return; scheduleReapply();} );
A material_variant action swaps materials deep inside the glTF's own scene graph, so it reports
variantRoot rather than a material — re-read the registry after one of those.
Custom animations
Every glTF animation clip on a model is available, and the viewer's frame event gives you the
delta a THREE.AnimationMixer needs:
wp.hooks.addAction( 'PC.fe.viewer.runtime.ready', 'my-plugin/3d', function ( viewer, three, api ) { const THREE = api.getTHREE(); api.ensureObject3dLoaded( 12 ).then( function ( root ) { if ( ! root ) return; const clips = api.getObject3dAnimations( 12 ); const clip = clips.find( ( c ) => c.name === 'Open' ); if ( ! clip ) return; const mixer = new THREE.AnimationMixer( root ); const action = mixer.clipAction( clip, root ); action.play(); api.on( 'frame', function ( { deltaSeconds } ) { mixer.update( deltaSeconds ); // Without this the mixer advances but nothing is drawn. api.requestRender(); } ); api.on( 'runtime:dispose', function () { mixer.stopAllAction(); mixer.uncacheRoot( root ); } ); } );} );
Use ensureObject3dLoaded() rather than reaching into the scene graph: models can be configured
to load lazily, and this resolves once the one you want is actually mounted.
Custom postprocessing passes
To run a full-screen shader over the finished image, contribute a pass through the
PC.3d.postprocessingPasses filter:
wp.hooks.addFilter( 'PC.3d.postprocessingPasses', 'my-plugin/3d', function ( factories ) { factories.push( function ( ctx ) { // Skip the cost on phones. if ( ctx.isMobile ) return null; const pass = new ctx.passes.ShaderPass( { uniforms: { tDiffuse: { value: null }, uAmount: { value: 0.4 }, }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); } `, fragmentShader: ` uniform sampler2D tDiffuse; uniform float uAmount; varying vec2 vUv; void main() { vec4 c = texture2D( tDiffuse, vUv ); float d = distance( vUv, vec2( 0.5 ) ); gl_FragColor = vec4( c.rgb * ( 1.0 - d * uAmount ), c.a ); } `, } ); pass.pcStage = 'after-output'; return pass; } ); return factories;} );
You register a factory, not a pass. A pass needs the renderer and buffer size to build, and
neither exists when the viewer first asks whether anyone wants a composer at all. The factory is
called later, once per scene build, with everything it needs — and may be async.
The factory context
| Key | Meaning |
|---|---|
passes | ShaderPass, RenderPass, OutputPass, Pass, FullScreenQuad, CopyShader |
THREE | The viewer's Three.js instance |
renderer, scene, camera | The live objects |
width, height, pixelRatio | Buffer dimensions |
isMobile | Whether the viewport is phone-sized |
settings | The stored postprocessing settings for this product |
api | The runtime API |
ctx.passes is there so you do not need a build step: a plain enqueued script can construct a
pass without importing anything from Three.js.
Return null to opt out — per device, per product, per anything you can read from the context.
Properties you can set on a pass
| Property | Effect |
|---|---|
pcStage | 'before-output' (default) or 'after-output' |
pcAnimated | Set to true if the pass changes the image on its own |
pcStage decides which side of the OutputPass your shader runs on, and that decides what it is
handed. Before the output pass, colours are still scene-linear HDR — the right place for
anything light-like, such as a blur or a glow. After it, you get the display-referred image —
the right place for a look, such as a colour grade or a vignette.
pcAnimated matters because of on-demand rendering. A pass driven by a clock has no scene change
to key off, so without this flag it draws once and then appears frozen.
How it fits with the 3D Premium add-on
If the 3D Premium add-on is installed and its own effects are switched on, your passes are
appended to its chain — SSR, ambient occlusion, bloom, SMAA, then the output pass and colour
grade, then anything you marked after-output.
If the add-on is not installed, or none of its effects are enabled for the product, the viewer builds a minimal composer of its own — render, output, your passes — just to run them. A custom pass works with the core plugin alone; the add-on is not a prerequisite.
Other filters
| Filter | Purpose |
|---|---|
PC.3d.postprocessingPasses | Contribute passes (above). |
PC.3d.postprocessingEnabled | Force a composer on or off. Receives enabled, settings, { isMobile }. |
PC.3d.createPostprocessingLayer | Replace the entire effect chain. Single slot — first registration wins. |
PC.fe.viewer.main_view | Replace the viewer view outright. |
PC.fe.viewer.choice_action_handlers | Add choice action types. See below. |
Adding choice actions
Return an object mapping an action type to { apply, restore }. Both receive
( context, action, index ): the choice's context, the action row, and its position in the
choice's actions. apply runs while the choice is selected and not hidden by conditional logic,
and restore runs when that stops being true. Built-in types cannot be replaced.
The context carries target_object and target_scene (the choice's object and model), viewer,
and, for actions that move things, placement (the placement manager), placement_key (unique
per choice) and placement_priority. Register the action type in PHP through
mkl_pc_choice_default_settings so it appears in the admin.
wp.hooks.addFilter( 'PC.fe.viewer.choice_action_handlers', 'my-plugin/3d', function ( handlers ) { handlers.spin = { apply: function ( context, action ) { /* start spinning context.target_object */ }, restore: function ( context, action ) { /* stop */ }, }; return handlers;} );
Placing objects on anchors
api.placement.request( key, spec ) moves an object onto anchors until release( key ). For each
object, the request with the highest priority wins. When none is left, the object returns to where
it was modelled. spec takes a target (target_object, target_id as a composite id, or
target_object3d_id for a whole model), anchor_ids (composite ids; several give one copy each),
follow_rotation, follow_scale, priority (an array compared element by element), and
hide_if_unplaced (hide the object instead of showing it where it was modelled when no anchor can
be used).
File requests for model positions in PC.fe.viewer.placement.ready, before any model loads, so
models land in place as they arrive. The 3D Premium add-on uses this for model positions, layouts
and Move to anchor.
Cleaning up
runtime:dispose fires whenever the scene is torn down — the configurator closing, the customer
switching products, or a WebGL context loss. Anything you allocated is yours to release:
api.on( 'runtime:dispose', function () { myMaterial.dispose(); myTexture.dispose(); mixer.stopAllAction();} );
Passes returned from a factory are the exception: the viewer disposes those for you, and calls your factory again when the scene is rebuilt.
Everything you took from the API — scene, camera, materials, objects — is invalid after this
point. Do not cache it across a dispose; wait for the next runtime.ready.