Guide

The 3D runtime API

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:

javascript my-3d-customisations.js
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();} );
Getting the API.

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:

php plugin.php
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	);} );
Depend on wp-hooks and nothing else.

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

MemberReturns
apiVersionShape 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.
placementThe 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:

javascript my-3d-customisations.js
if ( typeof api.getMaterialRegistry === 'function' ) { /* … */ }
Check that an API member exists.

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.

javascript my-3d-customisations.js
material.uniforms.uTime.value = elapsed;api.requestRender();
On-demand rendering.

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:Foot that should read the same on all four legs, or a fold animation that should move every leg, loops over getScenesForObject3dId() or findObjectsByCompositeId().
  • 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:

javascript my-3d-customisations.js
const feet = typeof api.findObjectsByCompositeId === 'function'	? api.findObjectsByCompositeId( '12:Foot' )	: [ api.findObjectByCompositeId( '12:Foot' ) ].filter( Boolean );
Work with every copy, on older hosts too.

Events

Subscribe with api.on( name, callback ).

EventPayload
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:appliedSee 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:

ActionArguments
PC.fe.viewer.runtime.readyviewer, three, api
PC.fe.viewer.frameviewer, deltaSeconds, api
PC.fe.viewer.angle.changedviewer, previous, current, api
PC.fe.viewer.object3d.loadedviewer, object3dId, scene, animations, api
PC.fe.viewer.material.appliedviewer, payload, api
PC.fe.viewer.placement.changedviewer, target, anchors, copies, api
PC.fe.viewer.layout.variant_changedviewer, layoutId, variantId, api — fired by 3D Premium
PC.fe.viewer.placement.readyviewer, api — the placement manager exists and no model has loaded yet
PC.fe.viewer.context.lost / .restoredviewer, api
PC.fe.viewer.runtime.disposeviewer, three, api
PC.fe.viewer.webgl.unavailableviewer, error
PC.fe.viewer.runtime.eventviewer, eventName, payload, api — catch-all

Custom materials and shaders

You can replace any material in the scene from runtime.ready:

javascript my-3d-customisations.js
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();	} );} );
Patch a material from runtime.ready.

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:

KeyMeaning
phase'apply' when a choice became active, 'restore' when it was deselected
actionTypematerial_property, material_color_registry, material_texture, apply_material or material_variant
actionThe raw action as configured in the admin
materialThe affected THREE.Material, or null
materialNameIts name in the registry, or ''
meshesMeshes whose .material reference was reassigned. Empty unless actionType is apply_material
variantRootFor material_variant, the glTF root the variant was selected on
targetObject / targetSceneThe choice's 3D target
choice / layerThe Backbone models that caused it

So a shader that has to survive the configurator looks like this:

javascript my-3d-customisations.js
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();} );
Reapply a shader after a choice changes the material.

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:

javascript my-3d-customisations.js
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 );		} );	} );} );
Play a glTF animation clip.

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:

javascript my-3d-customisations.js
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;} );
Contribute a custom postprocessing pass.

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

KeyMeaning
passesShaderPass, RenderPass, OutputPass, Pass, FullScreenQuad, CopyShader
THREEThe viewer's Three.js instance
renderer, scene, cameraThe live objects
width, height, pixelRatioBuffer dimensions
isMobileWhether the viewport is phone-sized
settingsThe stored postprocessing settings for this product
apiThe 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

PropertyEffect
pcStage'before-output' (default) or 'after-output'
pcAnimatedSet 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

FilterPurpose
PC.3d.postprocessingPassesContribute passes (above).
PC.3d.postprocessingEnabledForce a composer on or off. Receives enabled, settings, { isMobile }.
PC.3d.createPostprocessingLayerReplace the entire effect chain. Single slot — first registration wins.
PC.fe.viewer.main_viewReplace the viewer view outright.
PC.fe.viewer.choice_action_handlersAdd 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.

javascript my-3d-customisations.js
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;} );
Add a choice action.

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:

javascript my-3d-customisations.js
api.on( 'runtime:dispose', function () {	myMaterial.dispose();	myTexture.dispose();	mixer.stopAllAction();} );
Cleaning up.

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.