TipsBlocks Web Technique Library

TB-0003

Wind Grass Field

Three.jsとWebGLで実装した、360度自由に視点を変えられる3D芝生フィールドです。高密度のBillboard Grass、3D文字、風のShader、Quaternionベースのトラックボールカメラ、自動復帰を組み合わせています。

3D Scene Background Effect Interaction Free Orbit Pointer Move GLSL
Interaction
マウスを上下左右・斜め方…
Runtime
Three.js / WebGL

MOTION PREVIEW

360度、視点が動く芝生フィールド。

マウスの移動方向に合わせてカメラが立体的に回転し、芝生を見る角度が連続的に変化します。草の揺れ、3D文字、影、地形の奥行きが組み合わさることで、静止画では表現できない立体感が生まれます。

PREVIEW MEDIA Preview MP4 / Poster を登録すると、ここへ自動表示されます。

LIVE DEMO

芝生の中を、自由に回り込む。

マウスを上下左右・斜め方向へ動かすと、フィールドの周囲を自由に回り込めます。マウス移動によって芝生にも局所的な風が発生します。操作を止めると、少し時間を置いてから自然に初期位置へ戻ります。

HOW TO INTERACT

Free Orbit

マウスを上下左右・斜め方向へ動かすと、フィールドの周囲を自由に回り込めます。マウス移動によって芝生にも局所的な風が発生します。操作を止めると、少し時間を置いてから自然に初期位置へ戻ります。

ABOUT THIS BLOCK

Wind Grass Fieldとは

Wind Grass Fieldは、高密度の芝生、3D文字、カメラワーク、風のアニメーションを組み合わせたThree.jsベースのインタラクティブ表現です。

特徴は、決められた方向から眺める3Dシーンではなく、マウス操作によって視点そのものを自由に変えられることです。水平方向だけでなく、上下や斜め方向にも連続して回転できるため、フィールドの形状や文字の立体感、芝生の密度をさまざまな角度から確認できます。

芝生はBillboard Grassを高密度に配置し、Shaderによる常時の風と、ポインター位置・移動速度に応じた局所的な風を重ねています。「TIPS BLOCKS」の文字は立体形状として芝生面に沿って配置し、厚み・ベベル・接地影によってフィールドとの一体感を持たせています。

操作を止めると、少し時間を置いてからカメラがゆっくり初期位置へ戻ります。自由度の高い操作性を持ちながら、閲覧者が視点を見失ったままにならないように設計しています。

Technology
GLSL / JavaScript / Three.js / WebGL
Interaction
Free Orbit / Pointer Move
External Library
Three.js 0.180.0
Reduced Motion
Supported

HOW IT WORKS

Wind Grass Fieldはどう動いているのか。

高密度Billboard Grass、Instancing、Shaderによる風、Quaternionカメラ、3D Typography、自動復帰を組み合わせて、操作できる立体フィールドを構成しています。

  1. 01

    Billboard Grassを高密度に配置する

    複数の草をまとめた透過カードを交差させ、小さな草のまとまりとして配置しています。一本ずつ草を3Dモデル化するよりも軽量で、密度の高い芝生を表現できます。

  2. 02

    Instancingで大量の芝生をまとめて描画する

    大量の芝生を個別のMeshとして扱わず、InstancedBufferGeometryとインスタンス属性を使ってまとめて描画します。位置・大きさ・回転・色のばらつきをインスタンスごとに持たせています。

  3. 03

    Shaderで風とポインターの突風を作る

    時間による複数の波を重ねて通常時の揺れを作り、マウス位置と移動速度から局所的なGustを追加します。ポインターを速く動かすほど、広い範囲の芝生が強く反応します。

  4. 04

    Quaternionで全方向へカメラを回転する

    カメラの向きをQuaternionとして積算し、現在のローカル軸を基準に上下左右・斜め方向の回転を追加します。縦方向の角度制限を設けず、極を越えて連続的に回り込めます。

  5. 05

    3D文字を芝生面へ沿わせる

    TIPS BLOCKSの文字はTextGeometryで厚みとベベルを持つ立体形状として生成し、地形の表面法線に沿って配置します。文字周囲の草を部分的に間引き、接地影を加えて芝生との一体感を作っています。

  6. 06

    操作が止まったら自然にホーム位置へ戻す

    マウス操作が約3秒ない場合、現在のQuaternionから初期Quaternionへゆっくり補間します。途中で再び操作すると復帰を止め、その場から自由回転へ戻ります。

CODE

360度動く芝生フィールドのコードを見る。

Three.jsによる3Dシーン、Billboard Grass、カスタムShader、Quaternionによるカメラ制御、3D文字の配置、自動復帰までをHTML・CSS・JavaScriptに分けて確認できます。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
  <meta name="robots" content="noindex,nofollow,noarchive">
  <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
  <meta http-equiv="Pragma" content="no-cache">
  <meta http-equiv="Expires" content="0">
  <link rel="canonical" href="https://tipsblocks.com/blocks/wind-grass-field/">
  <title>TipsBlocks — Wind Grass Field</title>
  <link rel="stylesheet" href="./style-v6-12.css?v=612">
</head>
<body>
  <canvas id="stage" aria-label="Wind Grass Field interactive Three.js demo"></canvas>

  <div class="tb-ui" aria-hidden="true">
    <div class="tb-brand">TIPSBLOCKS</div>
    <div class="tb-id">TB-0003 · WIND GRASS FIELD</div>

    <div class="tb-copy">
      <span class="tb-copy__label">THREE.JS / WEBGL / INSTANCED BILLBOARD GRASS</span>
      <strong>Wind Grass Field</strong>
      <span class="tb-copy__sub">A soft floating meadow with changing viewpoints and responsive wind.</span>
    </div>

    <div class="tb-hint">
      <span>MOVE — GUST</span>
      <i></i>
      <span>DRAG — ORBIT</span>
    </div>
  </div>

  <div id="loading" class="tb-loading">
    <span></span>
    <p>Growing the field...</p>
  </div>

  <script>
  window.setTimeout(() => {
    const el = document.getElementById("loading");
    if (el && !el.classList.contains("is-hidden")) {
      const p = el.querySelector("p");
      if (p) p.textContent = "Runtime did not start";
    }
  }, 8000);
  </script>
  <script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"
    }
  }
  </script>
  <script type="module" src="./script-v6-12.js?v=612"></script>
</body>
</html>
:root{
  --sky:#f1f2ef;
  --ink:#1b2420;
}
*{box-sizing:border-box}
html,body{
  margin:0;
  width:100%;
  height:100%;
  overflow:hidden;
  background:var(--sky);
  color:var(--ink);
  font-family:Arial,Helvetica,sans-serif;
}
body{overscroll-behavior:none}
#stage{
  position:fixed;
  inset:0;
  width:100%;
  height:100%;
  display:block;
  cursor:crosshair;
  touch-action:none;
}
.tb-ui{position:fixed;inset:0;z-index:3;pointer-events:none}
.tb-brand,.tb-id{
  position:absolute;
  top:22px;
  font-size:11px;
  font-weight:800;
  letter-spacing:.14em;
}
.tb-brand{left:24px}
.tb-id{right:24px}
.tb-copy{
  position:absolute;
  left:24px;
  bottom:24px;
  display:flex;
  flex-direction:column;
  gap:5px;
  max-width:420px;
}
.tb-copy__label{
  font-size:9px;
  font-weight:800;
  letter-spacing:.13em;
  opacity:.52;
}
.tb-copy strong{
  font-size:22px;
  line-height:1;
  letter-spacing:-.04em;
}
.tb-copy__sub{
  font-size:11px;
  line-height:1.5;
  opacity:.64;
}
.tb-hint{
  position:absolute;
  left:50%;
  bottom:27px;
  transform:translateX(-50%);
  display:flex;
  align-items:center;
  gap:11px;
  white-space:nowrap;
  font-size:9px;
  font-weight:800;
  letter-spacing:.11em;
  opacity:.68;
}
.tb-hint i{
  width:16px;
  height:1px;
  background:currentColor;
  opacity:.32;
}
.tb-loading{
  position:fixed;
  inset:0;
  z-index:10;
  display:grid;
  place-items:center;
  align-content:center;
  gap:12px;
  background:var(--sky);
  transition:opacity .45s ease,visibility .45s ease;
}
.tb-loading.is-hidden{opacity:0;visibility:hidden}
.tb-loading span{
  width:28px;
  height:28px;
  border:2px solid rgba(27,36,32,.13);
  border-top-color:var(--ink);
  border-radius:50%;
  animation:spin .8s linear infinite;
}
.tb-loading p{
  margin:0;
  font-size:10px;
  font-weight:800;
  letter-spacing:.12em;
  text-transform:uppercase;
  opacity:.54;
}
@keyframes spin{to{transform:rotate(360deg)}}

@media(max-width:700px){
  .tb-brand,.tb-id{top:15px;font-size:9px}
  .tb-brand{left:15px}
  .tb-id{right:15px}
  .tb-copy{left:15px;bottom:52px;max-width:260px}
  .tb-copy strong{font-size:18px}
  .tb-copy__sub{display:none}
  .tb-hint{bottom:18px;font-size:8px}
}
import * as THREE from "three";
import { FontLoader } from "three/addons/loaders/FontLoader.js";
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";

const canvas = document.getElementById("stage");
const loading = document.getElementById("loading");
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const mobile = matchMedia("(max-width:700px)").matches;

const scene = new THREE.Scene();
const SKY = new THREE.Color("#f1f2ef");
scene.background = SKY;
scene.fog = new THREE.Fog(SKY, 17.5, 34);

const renderer = new THREE.WebGLRenderer({
  canvas,
  antialias: !mobile,
  alpha: false,
  powerPreference: "high-performance"
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;

// Soft daylight used mainly by the extruded letters.
// Grass uses a custom shader and is not dependent on these lights.
const hemi = new THREE.HemisphereLight(0xffffff, 0xcdd5c9, 1.15);
scene.add(hemi);

const sun = new THREE.DirectionalLight(0xffffff, 0.95);
sun.position.set(5.5, 8.0, 3.0);
scene.add(sun);

const rim = new THREE.DirectionalLight(0xffffff, 0.32);
rim.position.set(-4.5, 3.5, -5.0);
scene.add(rim);

const camera = new THREE.PerspectiveCamera(38, 1, .1, 70);
const clock = new THREE.Clock();

let W = 1;
let H = 1;

const ISLAND = {
  rx: 7.25,
  ry: 2.65,
  rz: 5.55,
  cy: -1.55
};

const orbit = {
  radius:16.8,
  dragging:false,
  initialized:false,
  q:new THREE.Quaternion(),
  targetQ:new THREE.Quaternion(),
  homeQ:new THREE.Quaternion(),
  angularVelocity:new THREE.Vector2(0,0)
};

const pointer = new THREE.Vector2(99, 99);
let pointerActive = false;
const pointerWorld = new THREE.Vector3(999,999,999);
const pointerTarget = new THREE.Vector3(999,999,999);
const raycaster = new THREE.Raycaster();
const orbitTarget = new THREE.Vector3(0,.28,0);
const orbitBase = new THREE.Vector3(0,0,orbit.radius);
const tmpRight = new THREE.Vector3();
const tmpUp = new THREE.Vector3();
const tmpDir = new THREE.Vector3();
const qYaw = new THREE.Quaternion();
const qPitch = new THREE.Quaternion();
const qStep = new THREE.Quaternion();
const titleShadows = [];

let rawSpeed = 0;
let lastX = innerWidth * .5;
let lastY = innerHeight * .5;
let lastT = performance.now();
let lastInteractionAt = performance.now();

const RETURN_DELAY_MS = 3000;
const RETURN_SPEED = 0.72;

function clamp(v,a,b){ return Math.max(a,Math.min(b,v)); }
function ease(a,b,k){ return a+(b-a)*k; }

function seeded(seed){
  let s = seed >>> 0;
  return ()=>{
    s += 0x6D2B79F5;
    let t = Math.imul(s ^ s>>>15,1|s);
    t ^= t + Math.imul(t ^ t>>>7,61|t);
    return ((t ^ t>>>14)>>>0)/4294967296;
  };
}

function islandTopY(x,z){
  const q = (x*x)/(ISLAND.rx*ISLAND.rx) + (z*z)/(ISLAND.rz*ISLAND.rz);
  if(q >= 1) return ISLAND.cy;
  return ISLAND.cy + ISLAND.ry * Math.sqrt(1-q);
}

function islandNormal(x,z){
  const y = islandTopY(x,z);
  const nx = x/(ISLAND.rx*ISLAND.rx);
  const ny = (y-ISLAND.cy)/(ISLAND.ry*ISLAND.ry);
  const nz = z/(ISLAND.rz*ISLAND.rz);
  return new THREE.Vector3(nx,ny,nz).normalize();
}

function buildIsland(){
  const geo = new THREE.SphereGeometry(1,128,64);
  geo.scale(ISLAND.rx,ISLAND.ry,ISLAND.rz);
  geo.translate(0,ISLAND.cy,0);

  // Geometry is kept only for pointer raycasting.
  // The base itself is not rendered, so no flat pale layer can show through.
  const mat = new THREE.MeshBasicMaterial({
    transparent:true,
    opacity:0,
    colorWrite:false,
    depthWrite:false
  });

  const mesh = new THREE.Mesh(geo,mat);
  scene.add(mesh);
  return mesh;
}

function makeGrassTexture(){
  const size = 256;
  const c = document.createElement("canvas");
  c.width = c.height = size;
  const ctx = c.getContext("2d");

  ctx.clearRect(0,0,size,size);
  ctx.lineCap = "round";

  const rand = seeded(6060);

  // Dense, short tufts. The texture itself carries many blades,
  // so each billboard represents a small clump rather than a single blade.
  for(let i=0;i<110;i++){
    const x = size*(.08+rand()*.84);
    const base = size*(.94+rand()*.035);
    const h = size*(.20+rand()*.57);
    const lean = (rand()-.5)*size*.15;
    const mid = (rand()-.5)*size*.07;

    ctx.strokeStyle = "rgba(255,255,255,"+(0.62+rand()*.36).toFixed(3)+")";
    ctx.lineWidth = 1.1+rand()*2.0;
    ctx.beginPath();
    ctx.moveTo(x,base);
    ctx.quadraticCurveTo(
      x+mid,
      base-h*.55,
      x+lean,
      base-h
    );
    ctx.stroke();
  }

  // Low soft mass near the roots to hide card intersections.
  const grad = ctx.createLinearGradient(0,size*.72,0,size);
  grad.addColorStop(0,"rgba(255,255,255,0)");
  grad.addColorStop(.65,"rgba(255,255,255,.12)");
  grad.addColorStop(1,"rgba(255,255,255,.38)");
  ctx.fillStyle = grad;
  ctx.fillRect(0,size*.70,size,size*.30);

  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.SRGBColorSpace;
  tex.minFilter = THREE.LinearMipmapLinearFilter;
  tex.magFilter = THREE.LinearFilter;
  tex.generateMipmaps = true;
  return tex;
}

function makeCrossCardGeometry(){
  const positions = [];
  const uvs = [];
  const indices = [];
  let baseIndex = 0;

  const addPlane = (angle)=>{
    const c = Math.cos(angle);
    const s = Math.sin(angle);

    const w = .64;
    const h = .82;

    const xL = -w*c;
    const zL = -w*s;
    const xR =  w*c;
    const zR =  w*s;

    // Slightly narrower top makes the card mass feel less rectangular.
    const txL = -w*.76*c;
    const tzL = -w*.76*s;
    const txR =  w*.76*c;
    const tzR =  w*.76*s;

    positions.push(
      xL,0,zL,
      xR,0,zR,
      txL,h,tzL,
      txR,h,tzR
    );
    uvs.push(
      0,0,
      1,0,
      0,1,
      1,1
    );
    indices.push(
      baseIndex+0,baseIndex+1,baseIndex+2,
      baseIndex+2,baseIndex+1,baseIndex+3
    );
    baseIndex += 4;
  };

  addPlane(0);
  addPlane(Math.PI/3);
  addPlane(Math.PI*2/3);

  const g = new THREE.InstancedBufferGeometry();
  g.setAttribute("position",new THREE.Float32BufferAttribute(positions,3));
  g.setAttribute("uv",new THREE.Float32BufferAttribute(uvs,2));
  g.setIndex(indices);
  return g;
}


function grassClearance(x,z){
  // Approximate glyph footprints for WIND / FIELD.
  // Grass is thinned only immediately around each letter, not across the whole row.
  const centers = [
    [-2.15,-1.08],[-.72,-1.05],[.70,-1.08],[2.05,-1.02],
    [-2.55,1.02],[-1.30,1.00],[-.05,1.02],[1.18,1.00],[2.45,1.05]
  ];

  let influence = 0.0;

  for(const c of centers){
    const dx=(x-c[0])/.62;
    const dz=(z-c[1])/.52;
    const d=Math.sqrt(dx*dx+dz*dz);

    // 1.0 near the glyph body, fading smoothly to zero around it.
    const m=1.0-smoothstepJS(.72,1.40,d);
    influence=Math.max(influence,m);
  }

  return influence;
}

function smoothstepJS(a,b,x){
  const t=clamp((x-a)/(b-a),0,1);
  return t*t*(3-2*t);
}

function buildGrass(island){
  const count = mobile ? 7200 : 16000;
  const geo = makeCrossCardGeometry();
  const tex = makeGrassTexture();

  const offsets = new Float32Array(count*3);
  const scales = new Float32Array(count);
  const rotations = new Float32Array(count);
  const tint = new Float32Array(count);
  const normals = new Float32Array(count*3);

  const rand = seeded(6003);

  let i=0;
  let attempts=0;

  while(i<count && attempts<count*4){
    attempts++;

    const a=rand()*Math.PI*2;
    const r=Math.sqrt(rand())*.985;
    const x=Math.cos(a)*ISLAND.rx*r;
    const z=Math.sin(a)*ISLAND.rz*r;
    const y=islandTopY(x,z);

    const clearance=grassClearance(x,z);

    // Keep a little grass between and around letters so the title still feels
    // embedded in the meadow. Directly over a glyph, most blades are removed.
    const keepChance=1.0-clearance*.86;
    if(rand()>keepChance) continue;

    offsets[i*3+0]=x;
    offsets[i*3+1]=y-.015;
    offsets[i*3+2]=z;

    const nx=x/(ISLAND.rx*ISLAND.rx);
    const ny=(y-ISLAND.cy)/(ISLAND.ry*ISLAND.ry);
    const nz=z/(ISLAND.rz*ISLAND.rz);
    const nl=Math.hypot(nx,ny,nz)||1;

    normals[i*3+0]=nx/nl;
    normals[i*3+1]=ny/nl;
    normals[i*3+2]=nz/nl;

    // Grass immediately beside the title is slightly shorter.
    const localScale=(.72+rand()*.58)*(1.0-clearance*.28);
    scales[i]=localScale;
    rotations[i]=rand()*Math.PI*2;
    tint[i]=rand();

    i++;
  }

  // In the unlikely event candidate rejection was excessive, render only
  // the instances actually populated.
  geo.instanceCount = i;

  geo.setAttribute("aOffset",new THREE.InstancedBufferAttribute(offsets,3));
  geo.setAttribute("aScale",new THREE.InstancedBufferAttribute(scales,1));
  geo.setAttribute("aRotation",new THREE.InstancedBufferAttribute(rotations,1));
  geo.setAttribute("aTint",new THREE.InstancedBufferAttribute(tint,1));
  geo.setAttribute("aNormal",new THREE.InstancedBufferAttribute(normals,3));

  const mat = new THREE.ShaderMaterial({
    uniforms:{
      uTime:{value:0},
      uMap:{value:tex},
      uBase:{value:new THREE.Color("#6f9369")},
      uTipA:{value:new THREE.Color("#a6c59d")},
      uTipB:{value:new THREE.Color("#bfd3b5")},
      uGustPoint:{value:new THREE.Vector3(999,999,999)},
      uGust:{value:0}
    },
    vertexShader:`
      precision highp float;

      attribute vec3 aOffset;
      attribute float aScale;
      attribute float aRotation;
      attribute float aTint;
      attribute vec3 aNormal;

      uniform float uTime;
      uniform vec3 uGustPoint;
      uniform float uGust;

      varying vec2 vUv;
      varying float vTint;
      varying float vFogDepth;

      mat2 rot2(float a){
        float c=cos(a),s=sin(a);
        return mat2(c,-s,s,c);
      }

      void main(){
        vUv=uv;
        vTint=aTint;

        vec3 p=position;
        p.xy*=aScale;

        p.xz=rot2(aRotation)*p.xz;

        float tip=pow(clamp(uv.y,0.0,1.0),1.6);

        float waveA=sin(uTime*1.32+aOffset.x*.58+aOffset.z*.31);
        float waveB=sin(uTime*.73-aOffset.x*.27+aOffset.z*.49);
        float waveC=sin(uTime*2.05+aOffset.x*.16-aOffset.z*.19);

        float d=distance(aOffset,uGustPoint);
        float gustMask=1.0-smoothstep(.12,4.9,d);
        float gustPulse=.82+.28*sin(uTime*5.1-d*2.15);

        float sway=(waveA*.13+waveB*.07+waveC*.035);
        float gust=gustMask*uGust*gustPulse;

        p.x+=(sway+gust*.72)*tip;
        p.z+=(sway*.34+gust*.44)*tip;
        p.y-=abs(sway+gust*.32)*.045*tip;

        vec3 N=normalize(aNormal);
        vec3 refAxis=abs(N.y)<.96 ? vec3(0.0,1.0,0.0) : vec3(1.0,0.0,0.0);
        vec3 T=normalize(cross(refAxis,N));
        vec3 B=normalize(cross(N,T));

        vec3 world=aOffset + T*p.x + N*p.y + B*p.z;
        vec4 mv=modelViewMatrix*vec4(world,1.0);
        vFogDepth=-mv.z;
        gl_Position=projectionMatrix*mv;
      }
    `,
    fragmentShader:`
      precision highp float;

      uniform sampler2D uMap;
      uniform vec3 uBase;
      uniform vec3 uTipA;
      uniform vec3 uTipB;
      uniform vec3 fogColor;
      uniform float fogNear;
      uniform float fogFar;

      varying vec2 vUv;
      varying float vTint;
      varying float vFogDepth;

      void main(){
        float alpha=texture2D(uMap,vUv).a;
        if(alpha<.13) discard;

        float h=smoothstep(0.0,1.0,vUv.y);
        vec3 tip=mix(uTipA,uTipB,vTint);
        vec3 col=mix(uBase,tip,h);

        // Fake AO at roots, bright soft tips.
        col*=mix(.84,1.05,h);

        float fogFactor=smoothstep(fogNear,fogFar,vFogDepth);
        col=mix(col,fogColor,fogFactor);

        gl_FragColor=vec4(col,1.0);
      }
    `,
    side:THREE.DoubleSide,
    transparent:false,
    depthWrite:true,
    fog:true
  });

  // ShaderMaterial doesn't auto-wire these fog uniforms, so bind them manually.
  mat.uniforms.fogColor={value:SKY};
  mat.uniforms.fogNear={value:16.5};
  mat.uniforms.fogFar={value:32.0};

  const mesh = new THREE.Mesh(geo,mat);
  mesh.frustumCulled=false;
  scene.add(mesh);

  return {mesh,mat};
}

function buildFallbackMarker(){
  const group = new THREE.Group();
  const mat = new THREE.MeshBasicMaterial({color:"#72766f"});

  const add = (w,h,d,x,z)=>{
    const m=new THREE.Mesh(new THREE.BoxGeometry(w,h,d),mat);
    m.position.set(x,0,z);
    group.add(m);
  };

  add(2.8,.58,.55,-1.9,-.65);
  add(.58,.58,2.8,-1.9,.2);

  add(.58,.58,2.9,.7,.15);
  add(2.1,.58,.55,1.45,-1.0);
  add(2.1,.58,.55,1.45,.15);
  add(2.1,.58,.55,1.45,1.25);

  group.position.y=islandTopY(0,0)+.22;
  group.rotation.y=-.16;
  scene.add(group);
  return group;
}

function buildText(){
  const group=new THREE.Group();
  group.visible=false;
  scene.add(group);

  const loader=new FontLoader();
  loader.load(
    "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/fonts/helvetiker_bold.typeface.json",
    font=>{
      const frontMat=new THREE.MeshStandardMaterial({
        color:"#50554f",
        roughness:.93,
        metalness:0.0
      });
      const sideMat=new THREE.MeshStandardMaterial({
        color:"#3f443f",
        roughness:.98,
        metalness:0.0
      });
      const shadowMat=new THREE.MeshBasicMaterial({
        color:"#394238",
        transparent:true,
        opacity:.26,
        depthWrite:false
      });

      const createGlyph=(char,size=1.04)=>{
        const geo=new TextGeometry(char,{
          font,
          size,
          depth:.72,
          curveSegments:5,
          bevelEnabled:true,
          bevelThickness:.065,
          bevelSize:.035,
          bevelSegments:2
        });
        geo.computeBoundingBox();
        const box=geo.boundingBox;
        const width=box.max.x-box.min.x;

        // Center horizontally, keep baseline at y=0, and center thickness.
        geo.translate(
          -(box.min.x+box.max.x)/2,
          -box.min.y,
          -(box.min.z+box.max.z)/2
        );
        return {geo,width};
      };

      const makeShadowGeo=(srcGeo)=>{
        const geo=srcGeo.clone();
        geo.scale(1.05,1.05,.14);
        return geo;
      };

      const placeWord=(text, config)=>{
        const letters=[...text].map(ch=>createGlyph(ch, config.size ?? 1.04));
        const gap=config.gap ?? .30;
        const total=letters.reduce((sum,item)=>sum+item.width,0)+gap*(letters.length-1);

        let cursor=-total/2;

        const xJ=config.xJ || [];
        const zJ=config.zJ || [];
        const yJ=config.yJ || [];
        const roll=config.roll || [];
        const extraYaw=config.extraYaw || [];
        const perSize=config.perSize || [];

        letters.forEach((item,i)=>{
          const x = cursor + item.width/2 + (xJ[i] || 0);
          const u = total > 0 ? x/(total/2) : 0;

          // A looser path with more character than straight aligned rows.
          const z = (config.baseZ || 0)
                  + (config.curveAmp || 0.12) * Math.sin(u * (config.curveFreq || 1.25))
                  + (config.secondaryAmp || 0.0) * Math.sin(u * (config.secondaryFreq || 2.6) + (config.secondaryPhase || 0.0))
                  + (zJ[i] || 0);

          const y = islandTopY(x,z);
          const n = islandNormal(x,z);

          // Word direction follows the local path and is projected onto the tangent plane.
          const sampleX = x + .18;
          const sampleZ = z + (config.curveAmp || 0.12) * .18 * Math.cos(u * (config.curveFreq || 1.25));
          const dirGuess = new THREE.Vector3(sampleX-x, islandTopY(sampleX,sampleZ)-y, sampleZ-z).normalize();

          const tangent = dirGuess.clone().sub(n.clone().multiplyScalar(dirGuess.dot(n))).normalize();
          const bitangent = new THREE.Vector3().crossVectors(n, tangent).normalize();

          const glyphGeo = item.geo.clone();
          const sizeScale = perSize[i] || 1.0;
          glyphGeo.scale(sizeScale, sizeScale, 1.0);

          const mesh = new THREE.Mesh(glyphGeo, [frontMat, sideMat]);

          // local x = along word direction
          // local y = across the face within the surface plane
          // local z = extrusion thickness upward from the meadow
          const basis = new THREE.Matrix4().makeBasis(tangent, bitangent, n);
          mesh.quaternion.setFromRotationMatrix(basis);

          const lift = .40 + (yJ[i] || 0);
          const pos = new THREE.Vector3(x,y,z).add(n.clone().multiplyScalar(lift));
          mesh.position.copy(pos);

          // More personality, but still readable as words.
          mesh.rotateOnAxis(bitangent, (roll[i] || 0) * 0.85);
          mesh.rotateOnAxis(n, (extraYaw[i] || 0) * 0.9);

          group.add(mesh);

          // Contact shadow hugging the grass surface under the glyph.
          const shadowGeo = makeShadowGeo(glyphGeo);
          const shadow = new THREE.Mesh(shadowGeo, shadowMat);
          shadow.quaternion.copy(mesh.quaternion);
          shadow.position.copy(new THREE.Vector3(x,y,z).add(n.clone().multiplyScalar(.055)));
          shadow.rotateOnAxis(bitangent, (roll[i] || 0) * 0.5);
          shadow.rotateOnAxis(n, (extraYaw[i] || 0) * 0.65);
          shadow.renderOrder = 2;
          group.add(shadow);
          titleShadows.push(shadow);

          cursor += item.width + gap;
        });
      };

      // More relaxed arrangement, less mechanical alignment.
      placeWord("TIPS", {
        baseZ:-1.00,
        size:1.26,
        gap:.44,
        curveAmp:.19,
        curveFreq:1.02,
        secondaryAmp:.05,
        secondaryFreq:2.9,
        secondaryPhase:.35,
        xJ:[-.08,.03,-.04,.09],
        zJ:[-.07,.05,-.02,.08],
        yJ:[.02,.05,.02,.06],
        roll:[-.15,.06,-.09,.12],
        extraYaw:[-.05,.03,-.02,.05],
        perSize:[1.03,.98,1.00,1.05]
      });

      placeWord("BLOCKS", {
        baseZ:1.10,
        size:1.08,
        gap:.34,
        curveAmp:.18,
        curveFreq:1.10,
        secondaryAmp:.05,
        secondaryFreq:2.75,
        secondaryPhase:1.0,
        xJ:[.02,-.05,.05,-.03,.03,-.02,.05],
        zJ:[.05,-.07,.03,-.04,.02,-.03,.06],
        yJ:[.03,.01,.04,.02,.05,.02,.06],
        roll:[.09,-.10,.07,-.08,.06,-.07,.10],
        extraYaw:[.03,-.04,.02,-.03,.02,-.02,.04],
        perSize:[1.02,.98,1.03,.97,1.01,.98,1.04]
      });

      group.visible=true;
      fallback.visible=false;
    },
    undefined,
    ()=>{}
  );

  return group;
}

const island=buildIsland();
const grass=buildGrass(island);
const fallback=buildFallbackMarker();
const title3D=buildText();

function resize(){
  W=innerWidth;
  H=innerHeight;
  renderer.setPixelRatio(Math.min(devicePixelRatio||1,mobile?1.15:1.55));
  renderer.setSize(W,H,false);
  camera.aspect=W/H;
  camera.updateProjectionMatrix();
}
addEventListener("resize",resize,{passive:true});
resize();

function updateCamera(dt,t){
  if(!orbit.initialized){
    // Pleasant default angle used as the natural "home" position.
    const yawQ=new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0,1,0),-.72);
    const pitchQ=new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0),-.62);

    orbit.homeQ.copy(yawQ).multiply(pitchQ).normalize();
    orbit.q.copy(orbit.homeQ);
    orbit.targetQ.copy(orbit.homeQ);
    orbit.initialized=true;
  }

  const idleMs=performance.now()-lastInteractionAt;
  const returning=idleMs>RETURN_DELAY_MS && !orbit.dragging;

  if(returning){
    // First let any flick inertia fade, then ease the target back to home.
    orbit.angularVelocity.multiplyScalar(.80);

    // Frame-rate-independent slow return.
    const returnMix=1.0-Math.exp(-RETURN_SPEED*dt);
    orbit.targetQ.slerp(orbit.homeQ,returnMix);

    // Once very close, remove tiny residual motion.
    if(orbit.targetQ.angleTo(orbit.homeQ)<.002){
      orbit.targetQ.copy(orbit.homeQ);
      orbit.angularVelocity.set(0,0);
    }
  }else{
    // Apply accumulated free-orbit velocity around the current local axes.
    const moveX=orbit.angularVelocity.x;
    const moveY=orbit.angularVelocity.y;

    tmpDir.copy(orbitBase).applyQuaternion(orbit.targetQ).normalize();
    tmpRight.set(1,0,0).applyQuaternion(orbit.targetQ).normalize();
    tmpUp.set(0,1,0).applyQuaternion(orbit.targetQ).normalize();

    qYaw.setFromAxisAngle(tmpUp,-moveX);
    qPitch.setFromAxisAngle(tmpRight,-moveY);
    qStep.copy(qYaw).multiply(qPitch);

    orbit.targetQ.premultiply(qStep).normalize();

    // Natural inertia during and just after interaction.
    orbit.angularVelocity.multiplyScalar(pointerActive ? .72 : .92);
  }

  // Smooth actual camera toward the target orientation.
  const followMix=returning ? .11 : .16;
  orbit.q.slerp(orbit.targetQ,followMix);

  const radius=orbit.radius+Math.sin(t*.16)*.42;
  const pos=orbitBase.clone().setLength(radius).applyQuaternion(orbit.q);

  camera.position.copy(orbitTarget).add(pos);

  camera.up.set(0,1,0).applyQuaternion(orbit.q).normalize();
  camera.lookAt(orbitTarget);
}

function updatePointerWorld(){
  if(pointer.x>2)return;
  raycaster.setFromCamera(pointer,camera);
  const hit=raycaster.intersectObject(island,false);
  if(hit.length){
    pointerTarget.copy(hit[0].point);
    pointerWorld.lerp(pointerTarget,.58);
  }
}

function onMove(e){
  pointerActive=true;

  const now=performance.now();
  lastInteractionAt=now;
  const dx=e.clientX-lastX;
  const dy=e.clientY-lastY;
  const elapsed=Math.max(1,now-lastT);
  const speed=Math.hypot(dx,dy)/elapsed;

  rawSpeed=Math.max(rawSpeed*.74,speed*1.22);

  lastX=e.clientX;
  lastY=e.clientY;
  lastT=now;

  pointer.x=e.clientX/W*2-1;
  pointer.y=-(e.clientY/H)*2+1;

  // True two-axis trackball: horizontal, vertical and diagonal motion all
  // accumulate continuously. No pitch clamp, so poles can be crossed.
  const sensitivity=.0048;
  orbit.angularVelocity.x += dx*sensitivity;
  orbit.angularVelocity.y += dy*sensitivity;

  // Keep extreme flicks stable without limiting total rotation.
  orbit.angularVelocity.x=clamp(orbit.angularVelocity.x,-.18,.18);
  orbit.angularVelocity.y=clamp(orbit.angularVelocity.y,-.18,.18);

  updatePointerWorld();
}

function onDown(e){
  lastInteractionAt=performance.now();
  orbit.dragging=true;
  try{canvas.setPointerCapture(e.pointerId)}catch(_){}
}

function onUp(){
  orbit.dragging=false;
}

canvas.addEventListener("pointerenter",()=>{ pointerActive = true; },{passive:true});
canvas.addEventListener("pointermove",onMove,{passive:true});
canvas.addEventListener("pointerdown",onDown,{passive:true});
canvas.addEventListener("pointerup",onUp,{passive:true});
canvas.addEventListener("pointercancel",onUp,{passive:true});
canvas.addEventListener("pointerleave",()=>{
  orbit.dragging=false;
  pointerActive=false;
  pointer.x=99;
  pointer.y=99;
  rawSpeed*=.35;
});

let first=true;

function frame(){
  const dt=Math.min(.05,clock.getDelta());
  const t=clock.elapsedTime;

  updateCamera(dt,t);
  updatePointerWorld();

  rawSpeed*=reduced?.82:.93;
  const speed01=clamp(rawSpeed/.82,0,1);
  const targetGust=reduced?0:speed01*3.55;

  grass.mat.uniforms.uTime.value=t;
  grass.mat.uniforms.uGustPoint.value.copy(pointerWorld);
  grass.mat.uniforms.uGust.value=ease(
    grass.mat.uniforms.uGust.value,
    targetGust,
    .28
  );

  if(!reduced && !title3D.visible){
    fallback.rotation.z=Math.sin(t*.19)*.010;
  }

  renderer.render(scene,camera);

  if(first){
    first=false;
    requestAnimationFrame(()=>loading?.classList.add("is-hidden"));
  }
  requestAnimationFrame(frame);
}

frame();

USE CASE

3Dの動きをWebデザインへどう活用できるか。

通常の静止画や動画ではなく、閲覧者自身の操作で見え方が変化するメインビジュアルや背景として活用できます。

Hero / Main Visual

Hero / Main Visual

企業サイトやブランドサイトのファーストビューで、静止画や動画とは異なる操作可能なメインビジュアルとして利用できます。

Portfolio / Creative Website

Portfolio / Creative Website

WebGLやThree.jsを使った表現力を見せたいポートフォリオ、制作会社、クリエイターサイトのキービジュアルに向いています。

Event / Campaign

Event / Campaign

イベント、展示、キャンペーンなど、世界観そのものを体験させたいページの背景や導入演出として利用できます。

TECHNICAL NOTES

実装前に確認しておきたいこと。

WebGL、ES Modules、端末性能、タッチ操作、Reduced Motion、CDN依存などを確認してから実サイトへ組み込むBlockです。

Browser

WebGL、ES Modules、Import Maps、Pointer Eventsを使用します。公開前に対象ブラウザの現行バージョンで表示・操作を確認してください。

Performance

芝生はInstancedBufferGeometryでまとめて描画し、現在のruntimeではデスクトップ約16,000、モバイル約7,200インスタンスを使用しています。端末性能に応じて密度やPixel Ratioを調整できます。

Mobile

画面幅700px以下では芝生インスタンス数とPixel Ratioを抑えます。Pointer Eventsで操作できますが、実サイトへ組み込む場合はページスクロールとの干渉を確認してください。

Reduced Motion

prefers-reduced-motion: reduceを検出し、芝生の風や自動的な動きを抑える処理を含めています。