Recently I saw a cool holographic sticker effect on Twitter made by Baptiste Adrien, and I decided to take a stab at replicating it using Vue and TresJS. It turned out to be actually harder than I thought, but I’m happy with the results so far.
One of the things that still bugs me is that the React community has way more active contributors and libraries for ThreeJS, while Vue is still catching up. I tried reaching out to the TresJS Discord for help to see if I could find my way around it, but didn’t have any luck.
One of the challenges of this effect is the MeshTransmissionMaterial, which is not available in TresJS. So I had to use the MeshPhysicalMaterial from pmndrs/drei-vanilla and try to achieve the same effect using the available materials. But not everything is lost - TresJS provides a good abstraction around ThreeJS, and I could get a result closer to the React version.
The MeshTransmissionMaterial makes the base mesh transparent so it can reflect the background and items inside it, creating the desired effect. With little knowledge about shaders and 3D, this was indeed a fun journey to learn more about it.
Vue + TresJS
The following demo uses TresJS + Vue. Notice that the badge absorbs way more light than the React version - I’m still unsure why.
Vue implementation
But let’s see the actual code! Here is the main scene for the Vue version.
<script setup lang="ts">
import { OrbitControls, Environment, useGLTF } from "@tresjs/cientos";
import { TresCanvas } from "@tresjs/core";
import Stickers from "./sticker.vue";
import MeshTransmissionMaterial from "./material.vue";
const { nodes } = await useGLTF("/assets/three-model.glb");
</script>
<template>
<div class="relative size-[300px] lg:size-[600px]">
<TresCanvas style="width: 100%; height: 100%" :alpha="true" :antialias="true" power-preference="high-performance"
:shadows="true">
<suspense>
<TresMesh :geometry="nodes.Object_4.geometry" :rotation="[Math.PI / 2, 0, 0]" :cast-shadow="true"
:receive-shadow="true">
<MeshTransmissionMaterial />
</TresMesh>
</suspense>
<suspense>
<stickers />
</suspense>
<TresAmbientLight :intensity="1.5" />
<TresPerspectiveCamera visible :position="[0, 0, 6]" :fov="45" />
<OrbitControls :target="[0, 0, 0]" />
<suspense>
<Environment :files="'/assets/three-env.hdr'" :blur="0" :background="false" />
</suspense>
</TresCanvas>
</div>
</template>
Here you can find the stickers inside the main scene:
<script setup lang="ts">
import { useTexture } from "@tresjs/core";
import { DoubleSide } from "three";
const texture = await useTexture(["/assets/three-sticker.png"]);
</script>
<template>
<suspense>
<TresMesh>
<TresPlaneGeometry />
<TresMeshPhysicalMaterial :map="texture" :transparent="true" :clearcoat="1" :roughness="1" :metalness="0.8" :side="DoubleSide" />
</TresMesh>
</suspense>
</template>
And here’s the Transmission Material - a bit messy and buggy. If you’re reading this and have a better solution, please let’s get in touch! :p
<script setup lang="ts">
import { shallowRef, onMounted, nextTick } from "vue";
import { MeshTransmissionMaterial, MeshDiscardMaterial } from "@pmndrs/vanilla";
import { useFBO } from "@tresjs/cientos";
import { useRenderLoop, useTresContext } from "@tresjs/core";
import { BackSide, DoubleSide } from "three";
import type { TresObject } from "@tresjs/core";
import type { Camera, Texture, WebGLRenderTarget } from "three";
import type { Ref } from "vue";
const MeshTransmissionMaterialClass = shallowRef();
const { extend, scene, renderer, camera } = useTresContext();
const parent = shallowRef<TresObject>();
const backside = true;
const backsideThickness = 0;
const thickness = 0;
const backsideEnvMapIntensity = 0;
const fboResolution = 1500;
extend({ MeshTransmissionMaterial });
/**
* Finds the parent mesh using the specified material UUID.
*
* @param {THREE.Scene} scene - The Three.js scene to search.
* @param {string} materialUuid - The UUID of the material.
* @returns {THREE.Mesh | undefined} - The mesh using the material, or undefined if not found.
*/
function findMeshByMaterialUuid(scene: TresObject, materialUuid: string): TresObject {
let foundMesh;
scene.traverse((object: TresObject) => {
if (object.isMesh && object.material && object.material.uuid === materialUuid) {
foundMesh = object;
}
});
return foundMesh as unknown as TresObject;
}
const discardMaterial = new MeshDiscardMaterial();
const { onLoop } = useRenderLoop();
onMounted(async () => {
await nextTick();
parent.value = findMeshByMaterialUuid(scene.value as unknown as TresObject, MeshTransmissionMaterialClass.value.uuid);
});
const fboBack = useFBO({
width: fboResolution,
height: fboResolution,
}) as unknown as Ref<WebGLRenderTarget<Texture>>;
const fboMain = useFBO({
width: fboResolution,
height: fboResolution,
}) as unknown as Ref<WebGLRenderTarget<Texture>>;
let oldBg;
let oldEnvMapIntensity;
let oldTone;
onLoop(({ elapsed }) => {
MeshTransmissionMaterialClass.value.time = elapsed;
if (MeshTransmissionMaterialClass.value.buffer === fboMain.value.texture) {
if (parent.value) {
// Save defaults
oldTone = renderer.value.toneMapping;
oldBg = scene.value.background;
oldEnvMapIntensity = MeshTransmissionMaterialClass.value.envMapIntensity;
parent.value.material = discardMaterial;
if (backside) {
// Render into the backside buffer
renderer.value.setRenderTarget(fboBack.value);
renderer.value.render(scene.value, camera.value as Camera);
// And now prepare the material for the main render using the backside buffer
parent.value.material = MeshTransmissionMaterialClass.value;
parent.value.material.thickness = backsideThickness;
parent.value.material.side = BackSide;
parent.value.material.envMapIntensity = backsideEnvMapIntensity;
}
// Render into the main buffer
renderer.value.setRenderTarget(fboMain.value);
renderer.value.render(scene.value, camera.value as Camera);
parent.value.material = MeshTransmissionMaterialClass.value;
parent.value.material.thickness = thickness;
parent.value.material.side = DoubleSide;
// TODO: For some reason this makes the material really shinny, and i dont know why.
// parent.value.material.buffer = fboMain.value.texture
parent.value.material.envMapIntensity = oldEnvMapIntensity;
// Set old state back
scene.value.background = oldBg;
renderer.value.setRenderTarget(null);
renderer.value.toneMapping = oldTone;
}
}
});
defineExpose({
root: MeshTransmissionMaterialClass,
constructor: MeshTransmissionMaterial,
});
</script>
<template>
<TresMeshTransmissionMaterial ref="MeshTransmissionMaterialClass" :buffer="fboMain.texture" :transmission="0"
:_transmission="1" :anisotropic-blur="0.1" :thickness="0" :side="DoubleSide" />
</template>
React Three Fiber Implementation
And here you can find the same demo but with React Three Fiber. While the Vue implementation looks really close to this one, I cannot seem to get the same results. Maybe I’m missing some configuration, or it’s just the way React Three Fiber handles the materials and defaults.
import { Environment, MeshTransmissionMaterial, OrbitControls, useGLTF, useTexture } from '@react-three/drei'
import { Canvas } from '@react-three/fiber'
import { DoubleSide } from 'three'
const Stickers = ({ texturePath }: { texturePath: string }) => {
const texture = useTexture(texturePath)
return (
<>
<mesh>
<planeGeometry />
<meshPhysicalMaterial
map={texture}
transparent
clearcoat={1}
roughness={0}
side={DoubleSide}
/>
</mesh>
</>
)
}
export interface SceneProps {
modelPath: string
texturePath: string
}
export const Scene = ({ modelPath, texturePath }: SceneProps) => {
const { nodes } = useGLTF(modelPath)
return (
<div className="relative size-[300px] lg:size-[600px]">
<Canvas
shadows
camera={{ position: [0, 0, 6], fov: 45 }}
>
<OrbitControls
target={[0, 0, 0]}
/>
<group dispose={null}>
<mesh
castShadow
receiveShadow
geometry={nodes.Object_4.geometry}
rotation={[Math.PI / 2, 0, 0]}
>
<MeshTransmissionMaterial />
</mesh>
</group>
<Stickers texturePath={texturePath} />
<ambientLight intensity={2} />
<Environment preset="city" blur={0} />
</Canvas>
</div>
)
}
Once again, such a nice challenge and learning experience! I hope you enjoyed this post, and if you have any suggestions or tips, please let me know! All credits to Baptiste Adrien and Vercel for the amazing idea!