Import Animated Character In ThreeJS For WordPress Block Plugin
Import an animated character in Threejs for WordPress block plugin and update scene / model attributes from DOM elements.

In this post I will demonstrate how I was able to import an animated character in ThreeJS for WordPress block plugin.

Choose Animation:

Below I created my own bundle of ThreeJS to include for the WordPress block plugin. I have a previous post on how to do this in the link below.

I needed to include the following scripts as well as the main ThreeJS script to the bundled library to use.

GLTFLoader

GLTFLoader script will load the model into the scene. The imported character to ThreeJS was exported from Blender as a GLTF model.

OrbitControls

OrbitControls script will allow scene interaction from the end user.

Character Modeling And Animation Resources

I created the character model using MakeHuman and exported it to Blender to rig the model and bind the animations.

I also sourced the animations for the model from Maximo which is an awesome resource for 3D character render assets. Their site assets provide a quick and easy way to get a project moving forward without needing to create your own which is an art in itself.

Animated Character In ThreeJS For WordPress Block Plugin

Code

Block.json

I created one attribute in this example for the editor to change the background color.

{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "r2creations24/my-custom-blocks-threejs-char",
	"version": "0.1.0",
	"title": "My Custom Blocks ThreeJS Character",
	"category": "widgets",
	"icon": "smiley",
	"description": "Custom blocks created by R2creations24.",
	"example": {},
	"attributes": {
		"bgColor": {
			"type": "string",
			"default": "#219b8b"
		}
	},
	"supports": {
		"html": false
	},
	"textdomain": "my-custom-blocks-threejs-char",
	"editorScript": "file:./index.js",
	"editorStyle": "file:./index.css",
	"render": "file:./render.php",
	"style": "file:./style-index.css",
	"viewScript": "file:./view.js"
}

Save.js

In the save.js file for the WordPress block plugin, I returned null because we are creating dynamic rendering.

export default function save() {
	return null;
}

Stylesheet for the Editor and Frontend Using Style.scss

I provided the styling for the block plugin using the style.scss file. This will be the styling for the block plugin in the editor as well as how its displayed to the user when published. In this example, I am setting the elements in the block plugin to be stacked in the center.

.wp-block-r2creations24-my-custom-blocks-threejs-char {
	background-color: var(--bg-color);
	color: #fff;
	padding: 2px;
    display: block;
    margin: auto;
    align-items: center;
}

 .wp-block-r2creations24-my-custom-blocks-threejs-char #threejs_example {
	width: 90%;
    margin: auto;
    display: grid;
    align-content: center;
    margin: auto;
}

.wp-block-r2creations24-my-custom-blocks-threejs-char input[type="range"] {
	width: 50%;
	justify-self: center;
	display: block;
	margin: auto;
	margin-bottom: 5px;
}

.wp-block-r2creations24-my-custom-blocks-threejs-char #threejs_example > p {
	justify-self: center;
    text-align: center;
}

.wp-block-r2creations24-my-custom-blocks-threejs-char #threejs_canvas {
	aspect-ratio: 1/1;
	display: block;
	margin: auto;
	padding: 10px;
}

.wp-block-r2creations24-my-custom-blocks-threejs-char .colors, #animations {
	text-align: center;
	padding-bottom: 5px;
}

Plugin Root PHP File To Include Bundle script

You will need to add the custom bundle script you created using Webpack to your plugin root PHP file. Below is what you will need to add to the bottom of the file.

function r2creations24_my_custom_blocks_enqueue_script()
{   
	wp_enqueue_script( 'r2creations24_my_custom_blocks_script_custom_threejs', plugin_dir_url( __FILE__ ) . 'assets/scripts/custom_threejs.js' );
}

add_action('wp_enqueue_scripts', 'r2creations24_my_custom_blocks_enqueue_script');

Editor And Block Plugin Creation Using Edit.js

We have to adjust the edit.js file script quite a bit. I needed to use a few libraries to ensure the script runs the same in the editor as well as the front end in the render.php.

I used the following due to the nature of how WordPress loads the editor and the plugins in the editor.

useRef

useRef is used so the script will be able to bind to the element after the page is loaded.

useEffect

useEffect is used to ensure our scripts are run after the rest of the page is done loading.

useState

useState is used so you can still interact with the ThreeJS scene objects using DOM elements.

The Arrays For The Animations

I used two arrays to hold the references for the animations, arr and animArr. The arr variable holds the names of the animation while the animArr variable holds the actual animation.

Complete Edit.js Below

import { __ } from '@wordpress/i18n';

import { 
	useBlockProps,
	PanelColorSettings,
	InspectorControls
 		} from '@wordpress/block-editor';

import { 
    PanelBody
        } from '@wordpress/components';

import './editor.scss';

import { useRef, useEffect, useState } from '@wordpress/element';

import './editor.scss';

export default function Edit( { attributes, setAttributes } ) {

	const newStyle = {
        "--bg-color": attributes.bgColor
    };

    const blockProps = useBlockProps( { style: newStyle } );

	function setBgColor(value){
        if(value === undefined){
            setAttributes({bgColor: "#219b8b"});
        } else {
             setAttributes({bgColor: value});
        }
    }

	let camera, scene, renderer, clock, mixer, model;

	let currentAnim;

	const [ lightInt, setLightInt ] = useState( 7500 );

	const [ spotLight, setSpotLight ] = useState( "" );

	const [ obj, setModel ] = useState( "" );

	const [ box, setMaterialBox ] = useState( "" );

	const [ floor, setMaterialFloor ] = useState( "" );	

	const [ arr, setArr ] = useState( [] );

	const [ animArr, setAnimArr ] = useState( [] );

	const ref = useRef();
    const parentref = useRef();
	const animationsref = useRef();

			function changeSpotLight(value){
				setLightInt(value);
				if(!spotLight) return;
				spotLight.intensity = lightInt;
			}

			function changeTPose(){
				if(currentAnim < 0) return;
				animArr[currentAnim].fadeOut(1.0);	
				currentAnim = -1;			
			}	
		
			function changeWalkAnim(){
				if(currentAnim > -1){
					animArr[2].reset();
					animArr[currentAnim].crossFadeTo(animArr[2], 1.0);
					currentAnim = 2;
				} else {
					currentAnim = 2;
					animArr[currentAnim].reset();
					animArr[currentAnim].play();
					animArr[currentAnim].fadeIn(1.0);
				}
			}	

			function changeIdleAnim(){
				if(currentAnim > -1){
					animArr[0].reset();
					animArr[currentAnim].crossFadeTo(animArr[0], 1.0);
					currentAnim = 0;
				} else {
					currentAnim = 0;
					animArr[currentAnim].reset();
					animArr[currentAnim].play();
					animArr[currentAnim].fadeIn(1.0);
				}
			}

			function changeOldIdleAnim(){
				if(currentAnim > -1){
					animArr[1].reset();
					animArr[currentAnim].crossFadeTo(animArr[1], 1.0);
					currentAnim = 1;
				} else {
					currentAnim = 1;
					animArr[currentAnim].reset();
					animArr[currentAnim].play();
					animArr[currentAnim].fadeIn(1.0);
				}
			}		

			function changeModelColorTint(){
				if(!obj) return;
				obj.traverse(function (child) {
					if (child.isMesh) {
						// Access the material of the child mesh here
						if(child.name !== "baseobjMesh")
							child.material.color.set(randomColor());
					}
				});
			}

			function changeMatColorTint(){
				if(!box || !floor) return;
				box.material.color.set(randomColor());
				floor.material.color.set(randomColor());
			}
			
			function randomColor(){
				return Math.floor(Math.random() * 0xffffff);
			}

			const changeAnim = (value) => {
				if(value === 0){
					changeIdleAnim();
				} else if(value === 1){
					changeOldIdleAnim();
				} else if(value === 2){
					changeWalkAnim();
				} else {
					changeTPose();
				}					
			}

	useEffect(() => {


			init();

			function init() {
				clock = new THREE.Clock();

				camera = new THREE.PerspectiveCamera( 45, 1, 0.1, 200 );
				camera.aspect = parentref.current.clientWidth / parentref.current.clientWidth;
				
				camera.position.z += 75;
				camera.position.y += 25;
				camera.position.x -= 15;

				// scene

				scene = new THREE.Scene();

				const ambientLight = new THREE.AmbientLight( 0xffffff );
				scene.add( ambientLight );

				const light = new THREE.SpotLight( 0xffffff, lightInt );
				light.position.set(-20, 40, 15);
				light.target.position.y += 10;
				light.castShadow = true; 				
				light.angle = Math.PI / 6;
				light.penumbra = 1;
				light.decay = 2;
				light.distance = 100;
				light.shadow.mapSize.width = 1024;
				light.shadow.mapSize.height = 1024;
				light.shadow.camera.near = 1;
				light.shadow.camera.far = 10;
				light.shadow.focus = 2; 
				
				scene.add( light );

				const helper = new THREE.SpotLightHelper( light, 10 );
				scene.add( helper );

				setSpotLight(light);

				scene.add( camera );


 				const loader = new GLTFLoader().setPath( '../wp-content/plugins/my-custom-blocks/assets/models/' );
				loader.load( 'maximo.gltf', function ( gltf ) {

					model = gltf.scene;

					gltf.scene.traverse( ( object ) => {
						// Access the material of the mesh
						if ( object.isMesh ) {
							object.castShadow = true;
							object.receiveShadow = true;
						}
					} );

					mixer = new THREE.AnimationMixer( model );
					animationsref.current.innerHTML = "";
					gltf.animations.forEach( ( clip ) => {
						console.log(clip.name);
						setArr(arr, arr.push(clip.name));
						setArr(animArr, animArr.push(mixer.clipAction( clip )));
					} );
					
					setArr([...arr, "T-Pose"]);
						// plane
					const plane = new THREE.BoxGeometry(20, 20, 1, 1);
					const materialFloor = new THREE.MeshPhongMaterial({ color: randomColor() });

					//floor
					const fl = new THREE.Mesh(plane, materialFloor);
					fl.position.set(model.position.x, model.position.y-0.5, model.position.z);
					fl.rotation.x = Math.PI / 2 * 45;
					fl.receiveShadow = true;
					scene.add(fl);

					//backwall
					const backwall = new THREE.Mesh(plane, materialFloor);
					backwall.position.set(model.position.x, model.position.y + 9, model.position.z - 10);
					backwall.receiveShadow = true;
					scene.add(backwall);

					//sidewall
					const sidewall = new THREE.Mesh(plane, materialFloor);
					sidewall.position.set(model.position.x + 10, model.position.y + 9, model.position.z);
					sidewall.rotation.y = Math.PI / 2 * 135;
					sidewall.receiveShadow = true;
					scene.add(sidewall);

					const meshBox = new THREE.BoxGeometry(1, 1, 1, 1);
					const materialBox = new THREE.MeshPhongMaterial({ color: randomColor() });
					const bx = new THREE.Mesh(meshBox, materialBox);
					bx.position.set(model.position.x, model.position.y + 20, model.position.z-5);
					bx.castShadow = true;
					scene.add( bx );

					setMaterialBox(bx);
					setMaterialFloor(fl);

					//play then reset animation; weird glitch otherwise
					currentAnim = 0;
					animArr[currentAnim].play();
					animArr[currentAnim].fadeOut(1.0);	
					currentAnim = -1;
					
					scene.add( model );
					
					setModel(model);

					animate();
			
				} ); 
                
				renderer = new THREE.WebGLRenderer( { antialias: true, alpha: true, canvas: ref.current } );
				renderer.setSize( parentref.current.clientWidth * .50, parentref.current.clientWidth * .50);
				renderer.shadowMap.enabled = true;
        		renderer.shadowMap.type = THREE.PCFSoftShadowMap;

				const controls = new OrbitControls( camera, renderer.domElement );
				controls.minDistance = 4;
				controls.maxDistance = 100;
				controls.addEventListener( 'change', render );

				window.addEventListener( 'resize', onWindowResize );

			}

			function onWindowResize() {
				camera.aspect = parentref.current.clientWidth / parentref.current.clientWidth;
				camera.updateProjectionMatrix();
				renderer.setSize( parentref.current.clientWidth * .50, parentref.current.clientWidth * .50 );
			}

			function render() {
				renderer.render( scene, camera );
			}

			function animate(){
				requestAnimationFrame(animate);

  				if ( mixer ) mixer.update( clock.getDelta() );

				renderer.setClearColor(0xffffff, 0);
				render();
			}


	}, []);

	return (
		<>
		    <InspectorControls>
                <PanelBody title={ __( 'Settings', 'my-custom-blocks-threejs-char' ) }>
                    <PanelColorSettings
                        title = { __('Configure Color')}
                        colorSettings={
                            [
                                {
                                    value: attributes.bgColor,
                                    onChange: (value) => setBgColor(value),
                                    label: __('Background Color')
                                }
                            ]
                        }
                    />

                </PanelBody>
            </InspectorControls>
			<div { ...blockProps } ref={parentref}>
                <canvas ref={ref} width="450px" height="450px" id="threejs_canvas"></canvas>
				<input type="range" value={lightInt} min="0" max="10000" step="100" onChange={(event) => changeSpotLight(event.target.value)}/>
				<div id="threejs_example">
					<div class="colors">
						<input type="button" value="Box / Plane Color" onClick={changeMatColorTint} />
						<input type="button" value="Model Color" onClick={changeModelColorTint} />
					</div>
					<p>Choose Animation:</p>
					<div ref={animationsref}>
					{arr.map((item, index)=>{
						return (
						<div id="animations">
							<input key={index} type="radio" name="animations" onClick={() => changeAnim(index)} defaultChecked={index === arr.length-1 ? true : false}></input><label>{item}</label>
						</div>
					)
					})}
					</div>
				</div>
			</div>
		</>
	);
}

Render.php

Lastly I chose to load the block plugin dynamically so we are going to use the render.php file. The script is similar to how it is loaded in the edit.js but without needing to use the React libraries for useEffect, useState and useRef. This is more of a straightforward approach to using the script even outside of WordPress.

Complete Render.php Below

<?php

 $bgColor = $attributes['bgColor'];

 $style = "--bg-color: ".$bgColor.";";

?>
<div <?php echo get_block_wrapper_attributes(array("style" => $style)); ?>>
		<canvas id="threejs_canvas" width="450px" height="450px" ></canvas>
		<input type="range" value="7500" min="0" max="10000" step="100" onchange="changeSpotLight(this.value)"/>
		<div id="threejs_example">
			<div class="colors">
				<input type="button" value="Box / Plane Color" onclick="changeMatColorTint()">
				<input type="button" value="Model Color" onclick="changeModelColorTint()">
			</div>
			<p>Choose Animation:</p>
			<div id="animations"></div>
    	</div>
</div>

<script>

			let camera, scene, renderer, spotLight;

			let model, materialBox, materialFloor, box, floor, backwall, sidewall;

			let canvasParent;

			let clock, mixer;

			let lightInt = 7500;

			let currentAnim, idle, old_idle, walk;

			init();

			function init() {
				clock = new THREE.Clock();
				canvasParent = document.getElementById("threejs_example");
				let animSelection = document.getElementById("animations");

				camera = new THREE.PerspectiveCamera( 45, 1, 0.1, 200 );
				camera.aspect = canvasParent.clientWidth / canvasParent.clientWidth;
				
				camera.position.z += 75;
				camera.position.y += 25;
				camera.position.x -= 15;

				// scene

				scene = new THREE.Scene();

				const ambientLight = new THREE.AmbientLight( 0xffffff );
				scene.add( ambientLight );

				spotLight = new THREE.SpotLight( 0xffffff, lightInt );
				spotLight.position.set(-20, 40, 15);
				spotLight.target.position.y += 10;
				spotLight.castShadow = true; 				
				spotLight.angle = Math.PI / 6;
				spotLight.penumbra = 1;
				spotLight.decay = 2;
				spotLight.distance = 100;

				
				spotLight.shadow.mapSize.width = 1024;
				spotLight.shadow.mapSize.height = 1024;
				spotLight.shadow.camera.near = 1;
				spotLight.shadow.camera.far = 10;
				spotLight.shadow.focus = 2; 
				scene.add( spotLight );

				const helper = new THREE.SpotLightHelper( spotLight, 10 );
				scene.add( helper );

				scene.add( camera );


 						const loader = new GLTFLoader().setPath( '<?php echo plugins_url(); ?>/my-custom-blocks/assets/models/' );
						loader.load( 'maximo.gltf', function ( gltf ) {

							model = gltf.scene;

							gltf.scene.traverse( ( object ) => {
								if ( object.isMesh ) {
									// Access the material of the mesh
									console.log( object.material ); 
									object.castShadow = true;
								}
							} );

							mixer = new THREE.AnimationMixer( model );
        
							gltf.animations.forEach( ( clip ) => {
								console.log(clip.name);
								if(clip.name === "idle"){
									idle = mixer.clipAction( clip );
									animSelection.innerHTML += `<input type="radio" onclick="changeIdleAnim()" name="animations" checked><label>${clip.name}</label><br/>`;
								}
								if(clip.name === "old_idle"){
									old_idle = mixer.clipAction( clip );
									animSelection.innerHTML += `<input type="radio" onclick="changeOldIdleAnim()" name="animations"><label>${clip.name}</label><br/>`;
								}
								if(clip.name === "walk"){
									walk = mixer.clipAction( clip );
									animSelection.innerHTML += `<input type="radio" onclick="changeWalkAnim()" name="animations"><label>${clip.name}</label><br/>`;
								}
								
							} );
							animSelection.innerHTML += `<input type="radio" onclick="changeTPose()" id="tpose" name="animations"><label for="tpose">T-Pose</label><br/>`;
							         // plane
							const plane = new THREE.BoxGeometry(20, 20, 1, 1);
							materialFloor = new THREE.MeshPhongMaterial({ color: randomColor() });

							//floor
							floor = new THREE.Mesh(plane, materialFloor);
							floor.position.set(model.position.x, model.position.y-0.5, model.position.z);
							floor.rotation.x = Math.PI / 2 * 45;
							floor.receiveShadow = true;
							scene.add(floor);

							//backwall
							backwall = new THREE.Mesh(plane, materialFloor);
							backwall.position.set(model.position.x, model.position.y + 9, model.position.z - 10);
							//backwall.rotation.x = Math.PI / 2 * 45;
							backwall.receiveShadow = true;
							scene.add(backwall);

							//sidewall
							sidewall = new THREE.Mesh(plane, materialFloor);
							sidewall.position.set(model.position.x + 10, model.position.y + 9, model.position.z);
							sidewall.rotation.y = Math.PI / 2 * 135;
							sidewall.receiveShadow = true;
							scene.add(sidewall);

							const meshBox = new THREE.BoxGeometry(1, 1, 1, 1);
							materialBox = new THREE.MeshPhongMaterial({ color: randomColor() });
							box = new THREE.Mesh(meshBox, materialBox);
							box.position.set(model.position.x, model.position.y + 20, model.position.z-5);
							box.castShadow = true;
							scene.add( box );

							currentAnim = idle;
							currentAnim.play();
							isAnimating = true;
							
							scene.add( model );
							
							animate();
			
						} ); 

				function onProgress( xhr ) {

					if ( xhr.lengthComputable ) {

						const percentComplete = xhr.loaded / xhr.total * 100;
						console.log( 'model ' + percentComplete.toFixed( 2 ) + '% downloaded' );

					}

				}

				function onError() {}

				const useCanvas = document.getElementById("threejs_canvas");
                
				renderer = new THREE.WebGLRenderer( { antialias: true, alpha: true, canvas: useCanvas } );
				renderer.setSize( canvasParent.clientWidth * .50, canvasParent.clientWidth * .50);
				renderer.shadowMap.enabled = true;
        		renderer.shadowMap.type = THREE.PCFSoftShadowMap;

				const controls = new OrbitControls( camera, renderer.domElement );
				controls.minDistance = 4;
				controls.maxDistance = 100;
				controls.addEventListener( 'change', render );

				window.addEventListener( 'resize', onWindowResize );
			}

			function changeLight(value){
				lightInt = value;
				if(pointLight !== null) 
					camera.remove( pointLight );
				
				pointLight = new THREE.PointLight( 0xffffff, lightInt );
				pointLight.castShadow = true;
				camera.add( pointLight );
				
			}

			function changeSpotLight(value){
				lightInt = value;
				spotLight.intensity = lightInt;
			}

			function changeTPose(){
				currentAnim.fadeOut(1.0);				
			}	
		
			function changeWalkAnim(){
				if(currentAnim){
					walk.reset();
					currentAnim.crossFadeTo(walk, 1.0);
					currentAnim = walk;
				} else {
					isAnimating = true;
					currentAnim = walk;
					currentAnim.reset();
					currentAnim.play();
					currentAnim.fadeIn(1.0);
				}
				
				walk.reset();
				walk.play();
				currentAnim.crossFadeTo(walk, 1.0);
				currentAnim = walk;
			}	

			function changeIdleAnim(){
				if(currentAnim){
					idle.reset();
					currentAnim.crossFadeTo(idle, 1.0);
					currentAnim = idle;
				} else {
					currentAnim = idle;
					currentAnim.reset();
					currentAnim.play();
					currentAnim.fadeIn(1.0);
				}
				idle.reset();
				idle.play();
				currentAnim.crossFadeTo(idle, 1.0);
				currentAnim = idle;
			}

			function changeOldIdleAnim(){
				old_idle.reset();
				old_idle.play();
				currentAnim.crossFadeTo(old_idle, 1.0);
				currentAnim = old_idle;
			}		

			function changeModelColorTint(){
				model.traverse(function (child) {
					if (child.isMesh) {
						console.log(child.name);
						// Access the material of the child mesh here
						if(child.name !== "baseobjMesh")
							child.material.color.set(randomColor());
					}
				});
			}

			function changeMatColorTint(){
				box.material.color.set(randomColor());
				floor.material.color.set(randomColor());
			}

			function randomColor(){
				return Math.floor(Math.random() * 0xffffff);
			}

			function onWindowResize() {

				camera.aspect = canvasParent.clientWidth / canvasParent.clientWidth;
				camera.updateProjectionMatrix();

				renderer.setSize( canvasParent.clientWidth * .50, canvasParent.clientWidth * .50 );
			}

			function render() {

				renderer.render( scene, camera );

			}

			function animate(){
				requestAnimationFrame(animate);
  				if ( mixer ) mixer.update( clock.getDelta() );
				renderer.setClearColor(0xffffff, 0);
				render();
			}


</script>

Hope this was helpful to import animated character in ThreeJS to WordPress for block plugins.