Codrops 원본

Building an Interactive Image Grid with Three.js

섹션 · MIT

섹션 더 보기
ORIGINAL PREVIEW
Building an Interactive Image Grid with Three.js 정적 미리보기

갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.

큰 화면으로 보기 새 탭

SOURCE FILES

원본 코드 읽기

28개 파일

수집한 원본 소스와 실행 안내를 함께 제공합니다.

final/index.html
파일 저장

<!DOCTYPE html>
<html lang="fr" class="no-js">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Codrops grid starter</title>
</head>

<body>
  <main>
    <div id="THREE-CONTAINER">
    </div>
  </main>
  <script type="module" src="/main.js"></script>
</body>
</html>
final/main.js
파일 저장

import "./style.css";

import { AssetsId } from "./scripts/constants/AssetsId";
import { AssetsManager } from "./scripts/managers/AssetsManager";
import { Grid } from "./scripts/components/Grid";
import { MainThree } from "./scripts/MainThree";
import { Ticker } from "./scripts/utils/Ticker";

export class Main {
  static async Init() {
    MainThree.Init();
    Ticker.Start();

    await this.#_LoadAssets();
    this.#_CreateScene();
  }

  static async #_LoadAssets() {
    AssetsManager.AddTexture(AssetsId.TEXTURE_1, "textures/img1.webp");
    AssetsManager.AddTexture(AssetsId.TEXTURE_2, "textures/img2.webp");
    AssetsManager.AddTexture(AssetsId.TEXTURE_3, "textures/img3.webp");
    AssetsManager.AddTexture(AssetsId.TEXTURE_4, "textures/img4.webp");
    AssetsManager.AddTexture(AssetsId.TEXTURE_5, "textures/img5.webp");
    AssetsManager.AddTexture(AssetsId.TEXTURE_6, "textures/img6.webp");
    AssetsManager.AddTexture(AssetsId.TEXTURE_7, "textures/img7.webp");
    AssetsManager.AddTexture(AssetsId.TEXTURE_8, "textures/img8.webp");
    AssetsManager.AddTexture(AssetsId.TEXTURE_9, "textures/img9.webp");
    AssetsManager.AddTexture(AssetsId.TEXTURE_10, "textures/img10.webp");

    await AssetsManager.Load();
  }

  static #_CreateScene() {
    MainThree.Add(new Grid());
  }
}

Main.Init();
함께 쓰는 파일 26개 보기
final/scripts/MainThree.js
파일 저장

import { ACESFilmicToneMapping, OrthographicCamera, Scene, WebGLRenderer } from "three";

import { DomElements } from "./constants/DomElements";
import { ExtendedObject3D } from "./utils/ExtendedObject3D";
import { Ticker } from "./utils/Ticker";

export class MainThree {
  static #_Scene = new Scene();

  /**  @type {HTMLElement} */
  static #_CanvasContainer;

  /**  @type {OrthographicCamera} */
  static #_Camera;

  /**  @type {WebGLRenderer} */
  static #_Renderer;

  /** * @type {Set<ExtendedObject3D>} */
  static #_ExtendedObject3D = new Set();

  // #region public methods
  static Init() {
    this.#_CreateRenderer();
    this.#_CreateCanvas();
    this.#_CreateCamera();

    window.addEventListener("resize", this.#_HandleResize);
    Ticker.Add(this.#_Update);
  }

  static Add(object3d) {
    object3d.traverse((child) => {
      if (child.isExtendedObject3D) {
        this.#_ExtendedObject3D.add(child);
      }
    });

    this.#_Scene.add(object3d);
  }

  static Remove(object3d) {
    object3d.traverse((child) => {
      if (child.isExtendedObject3D) {
        this.#_ExtendedObject3D.delete(child);
      }
    });

    this.#_Scene.remove(object3d);
  }
  // #endregion

  static #_CreateCanvas() {
    this.#_CanvasContainer = document.getElementById(
      DomElements.THREE_CONTAINER
    );
    this.#_CanvasContainer.appendChild(this.#_Renderer.domElement);
  }

  static #_CreateRenderer() {
    this.#_Renderer = new WebGLRenderer({
      antialias: true,
      alpha: true,
      powerPreference: "high-performance",
    });

    this.#_Renderer.toneMapping = ACESFilmicToneMapping;
    this.#_Renderer.setSize(window.innerWidth, window.innerHeight);
    this.#_Renderer.setPixelRatio(window.devicePixelRatio);
  }

  static #_CreateCamera() {
    this.#_Camera = new OrthographicCamera(-1, 1, 1, -1);
    this.#_Camera.position.z = 5;
  }

  static #_HandleResize = (event) => {
    this.#_Renderer.setSize(window.innerWidth, window.innerHeight);

    for (const object of this.#_ExtendedObject3D) {
      object.resize(event);
    }
  };

  static #_Update = (dt) => {
    this.#_Renderer.render(this.#_Scene, this.#_Camera);

    for (const object of this.#_ExtendedObject3D) {
      object.update(dt);
    }
  };

  // #region getters
  /** @returns {Scene} */
  static get Scene() {
    return this.#_Scene;
  }

  /** @returns {OrthographicCamera} */
  static get Camera() {
    return this.#_Camera;
  }

  /** @returns {WebGLRenderer} */
  static get Renderer() {
    return this.#_Camera;
  }

  /** @returns {HTMLCanvasElement} */
  static get Canvas() {
    return this.#_Renderer.domElement;
  }
  // #endregion
}
final/scripts/components/Card.js
파일 저장

import {
  Mesh,
  PlaneGeometry,
  Uniform,
  Vector2,
  Vector3,
} from "three";

import { AssetsId } from "../constants/AssetsId";
import { AssetsManager } from '../managers/AssetsManager';
import { CardMaterial } from "../materials/CardMaterial";
import { ExtendedObject3D } from "../utils/ExtendedObject3D";
import { Grid } from './Grid';
import { MainThree } from "../MainThree";
import { mapLinear } from "three/src/math/MathUtils.js";

export class Card extends ExtendedObject3D {
  static #_DefaultScale = new Vector3();
  static #_MaxScale = new Vector3();
  static #_Textures = [
    AssetsId.TEXTURE_1,
    AssetsId.TEXTURE_2,
    AssetsId.TEXTURE_3,
    AssetsId.TEXTURE_4,
    AssetsId.TEXTURE_5,
    AssetsId.TEXTURE_6,
    AssetsId.TEXTURE_7,
    AssetsId.TEXTURE_8,
    AssetsId.TEXTURE_9,
    AssetsId.TEXTURE_10,
  ]

  static Geometry = new PlaneGeometry(1, 1);

  #_defaultScale = new Vector3().setScalar(0.4);
  #_targetScale = new Vector3();

  #_gridPosition = new Vector3();
  #_targetPosition = new Vector3();

  mesh;
  material;
  gridPosition = new Vector2();

  constructor(i, j) {
    super();

    this.gridPosition.set(i, j);

    this.#_createMesh();
    this.#_setTargetPosition();
    this.scale.copy(this.#_defaultScale);
  }

  #_createMesh() {
    const randomIndex = Math.floor(Math.random() * Card.#_Textures.length);
    const textureId = Card.#_Textures[randomIndex];
    const texture = AssetsManager.GetAsset(textureId);

    this.material = new CardMaterial({
      uniforms: {
        uDistance: new Uniform(0),
        uTexture: new Uniform(texture),
      }
    });
    
    this.mesh = new Mesh(
      Card.Geometry,
      this.material
    );

    this.mesh.scale.copy(Card.#_DefaultScale);

    this.add(this.mesh);
  }

  #_setTargetPosition() {
    let { x, y } = this.gridPosition;

    const cardWidth = Card.#_DefaultScale.x * 0.5;
    const cardHeight = Card.#_DefaultScale.y * 0.5;

    x = mapLinear(
      x,
      0,
      Grid.COLUMNS,
      MainThree.Camera.left,
      MainThree.Camera.right
    ) + cardWidth;
    
    y =
      mapLinear(
        y,
        0,
        Grid.ROWS,
        MainThree.Camera.bottom,
        MainThree.Camera.top
      ) + cardHeight;

    this.#_gridPosition.set(x, y, 0);
  }

  static SetScale() {
    const aspect = window.innerWidth / window.innerHeight;
    const viewWidth = MainThree.Camera.right - MainThree.Camera.left;

    const columnWidth = viewWidth / Grid.COLUMNS;

    this.#_DefaultScale.x = columnWidth;
    this.#_DefaultScale.y = columnWidth * aspect;

		const isPortrait = window.innerWidth < window.innerHeight;
    const scaleFactor = isPortrait ? 2 : 4;

    this.#_MaxScale.copy(this.#_DefaultScale).multiplyScalar(scaleFactor);
  }

  resize(event) {
    this.mesh.scale.copy(Card.#_DefaultScale);
  }

  update(dt) {
    this.#_updateScale(dt);
    this.#_updatePosition(dt);
  }

  #_updatePosition(dt) {
    const distanceX = Math.abs(this.#_gridPosition.x - this.position.x);

    this.#_targetPosition.set(
      this.#_gridPosition.x,
      distanceX < 0.075 ? this.#_gridPosition.y : 0,
      this.position.z
    );

    this.position.lerp(
      this.#_targetPosition,
      1 - Math.pow(0.005 / Grid.COLUMNS, dt)
    );
  }

  #_updateScale(dt) {
    const aspect = window.innerWidth / window.innerHeight;

    const distanceX = Grid.MousePosition.x - this.position.x;
    let distanceY = Grid.MousePosition.y - this.position.y;
    distanceY /= aspect;

    let distance = Math.pow(distanceX, 2) + Math.pow(distanceY, 2);
    distance *= aspect > 1 ? 12 : 3;

    this.#_targetScale.lerpVectors(
      Card.#_DefaultScale,
      Card.#_MaxScale,
      Math.max(1 - distance, 0)
    );

    this.mesh.scale.lerp(this.#_targetScale, 1 - Math.pow(0.0002, dt));
    
    this.position.z = -distance;
    this.material.uniforms.uDistance.value = distance;
  }
}
final/scripts/components/Grid.js
파일 저장

import { Card } from './Card';
import { ExtendedObject3D } from "../utils/ExtendedObject3D";
import { Vector2 } from 'three';

export class Grid extends ExtendedObject3D {
  static COLUMNS = Math.floor(window.innerWidth / 100) | 1;
  static ROWS = Math.floor(window.innerHeight / 100) | 1;

  static MousePosition = new Vector2();
  #_targetMousePosition = new Vector2();

  constructor() {
    super();

    Card.SetScale();
    this.#_createCards();
    this.#_setListeners();
  }

  #_setListeners() {
    window.addEventListener('mousemove', this.#_updateMousePos)
    window.addEventListener('touchmove', this.#_updateMousePos)
  }

  #_createCards() {
    for(let i = 0; i < Grid.COLUMNS; i++) {
      for(let j = 0; j < Grid.ROWS; j++) {
        const card = new Card(i, j);
        this.add(card);
      }
    }
  }

  #_updateMousePos = (event) => {
    const isMobile = event.type === 'touchmove';
    
    const { clientX, clientY } = isMobile ? event.changedTouches[0] : event;

    const halfW = 0.5 * window.innerWidth;
    const halfH = 0.5 * window.innerHeight;

    // our position, normalized on a [-1, 1] range.
    const x = (clientX - halfW) / window.innerWidth * 2
    const y = -(clientY - halfH) / window.innerHeight * 2

    this.#_targetMousePosition.set(x, y)
  }

  resize() {
    Grid.COLUMNS = Math.floor(window.innerWidth / 100) | 1;
    Grid.ROWS = Math.floor(window.innerHeight / 100) | 1;
    
    Card.SetScale();
  }

  update(dt) {
    this.#_lerpMousePosition(dt);
  }

  #_lerpMousePosition(dt) {
    Grid.MousePosition.lerp(this.#_targetMousePosition, 1 - Math.pow(0.0125, dt));
  }
}
final/scripts/constants/AssetsId.js
파일 저장

export const AssetsId = Object.freeze({
  // Textures
  TEXTURE_1: "TEXTURE_1",
  TEXTURE_2: "TEXTURE_2",
  TEXTURE_3: "TEXTURE_3",
  TEXTURE_4: "TEXTURE_4",
  TEXTURE_5: "TEXTURE_5",
  TEXTURE_6: "TEXTURE_6",
  TEXTURE_7: "TEXTURE_7",
  TEXTURE_8: "TEXTURE_8",
  TEXTURE_9: "TEXTURE_9",
  TEXTURE_10: "TEXTURE_10",

  // Sounds
});
final/scripts/constants/DomElements.js
파일 저장

export const DomElements = {
  THREE_CONTAINER: 'THREE-CONTAINER',
}
final/scripts/loaders/TextureLoader.js
파일 저장

import { RepeatWrapping, SRGBColorSpace, TextureLoader as ThreeTextureLoader } from "three";

export class TextureLoader {
  static #_Loader = new ThreeTextureLoader();

  static async Load(path) {
    const texture = await this.#_Loader.loadAsync(path);
    
    texture.wrapS = RepeatWrapping;
    texture.wrapT = RepeatWrapping;
    texture.colorSpace = SRGBColorSpace;

    return texture;
  }
}
final/scripts/managers/AssetsManager.js
파일 저장

import { TextureLoader } from "../loaders/TextureLoader";

const ASSETS_TYPE = {
  TEXTURE: 'TEXTURE',
}

export class AssetsManager {
  static #_AssetsQueue = [];
  static #_Assets = new Map();

  /**
   * @param {AssetsId} id
   * @param {string} path
   * @returns {any}
  */
  static AddTexture(id, path) {
    this.#_AssetsQueue.push({
      id,
      path,
      type: ASSETS_TYPE.TEXTURE
    })  
  }

  static GetAsset(id) {
    return this.#_Assets.get(id);
  }

  static async Load() {
    let promises = this.#_AssetsQueue
      .map(async ({ id, path, type }) => {
        let asset = undefined;

        switch(type) {
          case ASSETS_TYPE.TEXTURE:
            asset = await TextureLoader.Load(path);
            this.#_Assets.set(id, asset);
            break;
          default:
            console.error(`Assets type: ${type} not recognized and ignored by AssetsManager.js`)
            break;
        }
      });

    await Promise.all(promises)
  }
}
final/scripts/materials/CardMaterial.js
파일 저장

import { ShaderMaterial } from "three";

export class CardMaterial extends ShaderMaterial {
  onBeforeCompile(shader) {
    shader.vertexShader = this.#_rewriteVertexShader();
    shader.fragmentShader = this.#_rewriteFragmentShader();
  }

  #_rewriteVertexShader() {
    return /* glsl */`
      varying vec2 vUv;

      void main() {
        vUv = uv;

        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.);
      }
    `;
  }

  #_rewriteFragmentShader() {
    return /* glsl */ `
      uniform sampler2D uTexture;
      uniform float uDistance;
      
      varying vec2 vUv;

      vec3 getLuminance(vec3 color) {
        vec3 luminance = vec3(0.2126, 0.7152, 0.0722);
        return vec3(dot(luminance, color));
      }

      void main() {
        vec4 image = texture(uTexture, vUv);
        float distanceFactor = min(max(uDistance, 0.), 1.);

        vec3 imageLum = getLuminance(image.xyz);
        vec3 color = mix(image.xyz, imageLum, distanceFactor);

        gl_FragColor = vec4(color, 1.);
      }
    `;
  }
}
final/scripts/utils/ExtendedObject3D.js
파일 저장

import { Object3D } from "three";

export class ExtendedObject3D extends Object3D {
  isExtendedObject3D = true;

  resize(event) {}
  
  update(dt) {}
}
final/scripts/utils/Ticker.js
파일 저장

export class Ticker {
  static #_IsRunning = false;
  static #_CurrentTime = 0;
  static #_ElapsedTime = 0;
  static #_Callbacks = new Set();

  // #region public
  static Start() {
    this.#_IsRunning = true;

    this.#_CurrentTime = performance.now();
    this.#_Raf();
  }

  static Stop() {
    this.#_IsRunning = false;
  }

  static Add(callback) {
    this.#_Callbacks.add(callback);
  }

  static Remove(callback) {
    this.#_Callbacks.delete(callback);
  }
  // #endregion

  // #region private
  static #_Raf = () => {
    this.#_Update();

    if (this.#_IsRunning) {
      requestAnimationFrame(this.#_Raf);
    }
  };

  static #_Update = () => {
    const now = performance.now();
    const prev = this.#_CurrentTime;

    const dt = now - prev;

    this.#_ElapsedTime += dt;
    this.#_CurrentTime = now;

    for (const func of this.#_Callbacks) {
      func(dt * 0.001);
    }
  };
  // #endregion

  // #region getters
  static get IsRunning() {
    return this.#_IsRunning;
  }

  static get CurrentTime() {
    return this.#_CurrentTime;
  }

  static get ElapsedTime() {
    return this.#_ElapsedTime;
  }

  static get ElapsedTimeInSeconds() {
    return this.#_ElapsedTime / 1000;
  }
  // #endregion
}
final/style.css
파일 저장

*,
*::after,
*::before {
  box-sizing: border-box;
}

:root {
  font-size: 12px;
  --color-text: #fff;
  --color-bg: #000;
  --color-link: #fff;
  --color-link-hover: #fff;
  --page-padding: 1.5rem;
}

body {
  margin: 0;
  padding: 0;

  width: 100svw;
  height: 100svh;

  overflow: hidden;
  overscroll-behavior: none;

  background: #F8F7F9;

  font-family: ui-monospace, monospace;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

header {
  /* visibility: hidden; */
}

#THREE-CONTAINER {
  position: absolute;
  top: 0;
  left: 0;

  width: 100%;
  height: 100%;
}

.frame {
  padding: 3rem var(--page-padding) 0;
  display: grid;
  z-index: 1000;
  position: relative;
  grid-row-gap: 1rem;
  grid-column-gap: 2rem;
  pointer-events: none;
  justify-items: start;
  grid-template-columns: auto auto;
  grid-template-areas:
    'title'
    'back'
    'archive'
    'github'
    'demos'
    'tags'
    'sponsor';

  #cdawrap {
    justify-self: start;
    grid-area: sponsor;
  }

  a,
  button {
    pointer-events: auto;
  }

  .frame__title {
    grid-area: title;
    font-size: inherit;
    margin: 0;
  }

  .frame__back {
    grid-area: back;
    justify-self: start;
  }

  .frame__archive {
    grid-area: archive;
    justify-self: start;
  }

  .frame__github {
    grid-area: github;
  }

  .frame__tags {
    grid-area: tags;
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
  }

  .frame__demos {
    grid-area: demos;
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
  }

  @media screen and (min-width: 53em) {
    padding: var(--page-padding);
    height: 100%;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    grid-template-columns: auto auto auto auto 1fr;
    grid-template-rows: auto auto;
    align-content: space-between;
    grid-template-areas:
      'title back github archive demos'
      'tags tags tags sponsor sponsor';

    .frame__tags {
      align-self: end;
    }

    .frame__demos,
    #cdawrap {
      justify-self: end;
      text-align: right;
      max-width: 300px;
    }
  }
}

.content {
  padding: var(--page-padding);
  display: flex;
  flex-direction: column;
  position: absolute;
  top: 0;
  left: 0;

  width: 100%;
  height: 100%;

  @media screen and (min-width: 53em) {
    min-height: 100vh;
    justify-content: center;
    align-items: center;
  }
}
starter/index.html
파일 저장

<!DOCTYPE html>
<html lang="fr" class="no-js">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Codrops grid starter</title>
</head>

<body>
  <main>
    <div id="THREE-CONTAINER">
    </div>
  </main>
  <script type="module" src="/main.js"></script>
</body>
</html>
starter/main.js
파일 저장

import './style.css'

import { AssetsId } from './scripts/constants/AssetsId';
import { AssetsManager } from './scripts/managers/AssetsManager';
import { Grid } from './scripts/components/Grid';
import { MainThree } from './scripts/MainThree'
import { Ticker } from './scripts/utils/Ticker';

export class Main {
  static async Init() {
    MainThree.Init();
    Ticker.Start();

    await this.#_LoadAssets();
    this.#_CreateScene();
  }

  static async #_LoadAssets() {
    AssetsManager.AddTexture(AssetsId.TEXTURE_1, 'textures/img1.webp');
    AssetsManager.AddTexture(AssetsId.TEXTURE_2, 'textures/img2.webp');
    AssetsManager.AddTexture(AssetsId.TEXTURE_3, 'textures/img3.webp');
    AssetsManager.AddTexture(AssetsId.TEXTURE_4, 'textures/img4.webp');
    AssetsManager.AddTexture(AssetsId.TEXTURE_5, 'textures/img5.webp');
    AssetsManager.AddTexture(AssetsId.TEXTURE_6, 'textures/img6.webp');
    AssetsManager.AddTexture(AssetsId.TEXTURE_7, 'textures/img7.webp');
    AssetsManager.AddTexture(AssetsId.TEXTURE_8, 'textures/img8.webp');
    AssetsManager.AddTexture(AssetsId.TEXTURE_9, 'textures/img9.webp');
    AssetsManager.AddTexture(AssetsId.TEXTURE_10, 'textures/img10.webp');

    await AssetsManager.Load();
  }

  static #_CreateScene() {
    MainThree.Add(new Grid());
  }
}

Main.Init()
starter/scripts/MainThree.js
파일 저장

import { OrthographicCamera, Scene, WebGLRenderer } from "three";

import { DomElements } from "./constants/DomElements";
import { ExtendedObject3D } from "./utils/ExtendedObject3D";
import { Ticker } from "./utils/Ticker";

export class MainThree {
  static #_Scene = new Scene();

  /**  @type {HTMLElement} */
  static #_CanvasContainer;

  /**  @type {OrthographicCamera} */
  static #_Camera;

  /**  @type {WebGLRenderer} */
  static #_Renderer;

  /** * @type {Set<ExtendedObject3D>} */
  static #_ExtendedObject3D = new Set();

  // #region public methods
  static Init() {
    this.#_CreateRenderer();
    this.#_CreateCanvas();
    this.#_CreateCamera();

    window.addEventListener("resize", this.#_HandleResize);
    Ticker.Add(this.#_Update);
  }

  static Add(object3d) {
    object3d.traverse((child) => {
      if (child.isExtendedObject3D) {
        this.#_ExtendedObject3D.add(child);
      }
    });

    this.#_Scene.add(object3d);
  }

  static Remove(object3d) {
    object3d.traverse((child) => {
      if (child.isExtendedObject3D) {
        this.#_ExtendedObject3D.delete(child);
      }
    });

    this.#_Scene.remove(object3d);
  }
  // #endregion

  static #_CreateCanvas() {
    this.#_CanvasContainer = document.getElementById(
      DomElements.THREE_CONTAINER
    );
    this.#_CanvasContainer.appendChild(this.#_Renderer.domElement);
  }

  static #_CreateRenderer() {
    this.#_Renderer = new WebGLRenderer({
      antialias: true,
      alpha: true,
      powerPreference: "high-performance",
    });

    this.#_Renderer.setSize(window.innerWidth, window.innerHeight);
    this.#_Renderer.setPixelRatio(window.devicePixelRatio);
  }

  static #_CreateCamera() {
    this.#_Camera = new OrthographicCamera(-1, 1, 1, -1);
    this.#_Camera.position.z = 5;
  }

  static #_HandleResize = (event) => {
    this.#_Renderer.setSize(window.innerWidth, window.innerHeight);

    for (const object of this.#_ExtendedObject3D) {
      object.resize(event);
    }
  };

  static #_Update = (dt) => {
    this.#_Renderer.render(this.#_Scene, this.#_Camera);

    for (const object of this.#_ExtendedObject3D) {
      object.update(dt);
    }
  };

  // #region getters
  /** @returns {Scene} */
  static get Scene() {
    return this.#_Scene;
  }

  /** @returns {OrthographicCamera} */
  static get Camera() {
    return this.#_Camera;
  }

  /** @returns {WebGLRenderer} */
  static get Renderer() {
    return this.#_Camera;
  }

  /** @returns {HTMLCanvasElement} */
  static get Canvas() {
    return this.#_Renderer.domElement;
  }

  static get Aspect() {
    return this
  }
  // #endregion
}
starter/scripts/components/Card.js
파일 저장

import { ExtendedObject3D } from "../utils/ExtendedObject3D";

export class Card extends ExtendedObject3D {
  constructor() {
    super();
  }

  resize(event) {}

  update(dt) {}
}
starter/scripts/components/Grid.js
파일 저장

import { ExtendedObject3D } from "../utils/ExtendedObject3D";

export class Grid extends ExtendedObject3D {
  constructor() {
    super();
  }

  resize(event) {
    
  }

  update(dt) {
    
  }
}
starter/scripts/constants/AssetsId.js
파일 저장

export const AssetsId = Object.freeze({
  // Textures
  TEXTURE_1: "TEXTURE_1",
  TEXTURE_2: "TEXTURE_2",
  TEXTURE_3: "TEXTURE_3",
  TEXTURE_4: "TEXTURE_4",
  TEXTURE_5: "TEXTURE_5",
  TEXTURE_6: "TEXTURE_6",
  TEXTURE_7: "TEXTURE_7",
  TEXTURE_8: "TEXTURE_8",
  TEXTURE_9: "TEXTURE_9",
  TEXTURE_10: "TEXTURE_10",

  // Sounds
});
starter/scripts/constants/DomElements.js
파일 저장

export const DomElements = {
  THREE_CONTAINER: 'THREE-CONTAINER',
}
starter/scripts/loaders/TextureLoader.js
파일 저장

import { RepeatWrapping, SRGBColorSpace, TextureLoader as ThreeTextureLoader } from "three";

export class TextureLoader {
  static #_Loader = new ThreeTextureLoader();

  static async Load(path) {
    const texture = await this.#_Loader.loadAsync(path);
    
    texture.wrapS = RepeatWrapping;
    texture.wrapT = RepeatWrapping;
    texture.colorSpace = SRGBColorSpace;

    return texture;
  }
}
starter/scripts/managers/AssetsManager.js
파일 저장

import { TextureLoader } from "../loaders/TextureLoader";

const ASSETS_TYPE = {
  TEXTURE: 'TEXTURE',
}

export class AssetsManager {
  static #_AssetsQueue = [];
  static #_Assets = new Map();

  /**
   * @param {AssetsId} id
   * @param {string} path
   * @returns {any}
  */
  static AddTexture(id, path) {
    this.#_AssetsQueue.push({
      id,
      path,
      type: ASSETS_TYPE.TEXTURE
    })  
  }

  static GetAsset(id) {
    return this.#_Assets.get(id);
  }

  static async Load() {
    let promises = this.#_AssetsQueue
      .map(async ({ id, path, type }) => {
        let asset = undefined;

        switch(type) {
          case ASSETS_TYPE.TEXTURE:
            asset = await TextureLoader.Load(path);
            this.#_Assets.set(id, asset);
            break;
          default:
            console.error(`Assets type: ${type} not recognized and ignored by AssetsManager.js`)
            break;
        }
      });

    await Promise.all(promises)
  }
}
starter/scripts/materials/CardMaterial.js
파일 저장

import { ShaderMaterial } from "three";

export class CardMaterial extends ShaderMaterial {
  onBeforeCompile(shader) {
    shader.vertexShader = this.#_rewriteVertexShader(shader.vertexShader);
    shader.fragmentShader = this.#_rewriteFragmentShader(shader.fragmentShader);
  }

  #_rewriteVertexShader(vS) {
    return vS;
  }

  #_rewriteFragmentShader(fS) {
    return fS;
  } 
}
starter/scripts/utils/ExtendedObject3D.js
파일 저장

import { Object3D } from "three";

export class ExtendedObject3D extends Object3D {
  isExtendedObject3D = true;

  resize(event) {}
  
  update(dt) {}
}
starter/scripts/utils/Ticker.js
파일 저장

export class Ticker {
  static #_IsRunning = false;
  static #_CurrentTime = 0;
  static #_ElapsedTime = 0;
  static #_Callbacks = new Set();

  // #region public
  static Start() {
    this.#_IsRunning = true;

    this.#_CurrentTime = performance.now();
    this.#_Raf();
  }

  static Stop() {
    this.#_IsRunning = false;
  }

  static Add(callback) {
    this.#_Callbacks.add(callback);
  }

  static Remove(callback) {
    this.#_Callbacks.delete(callback);
  }
  // #endregion

  // #region private
  static #_Raf = () => {
    this.#_Update();

    if (this.#_IsRunning) {
      requestAnimationFrame(this.#_Raf);
    }
  };

  static #_Update = () => {
    const now = performance.now();
    const prev = this.#_CurrentTime;

    const dt = now - prev;

    this.#_ElapsedTime += dt;
    this.#_CurrentTime = now;

    for (const func of this.#_Callbacks) {
      func(dt * 0.001);
    }
  };
  // #endregion

  // #region getters
  static get IsRunning() {
    return this.#_IsRunning;
  }

  static get CurrentTime() {
    return this.#_CurrentTime;
  }

  static get ElapsedTime() {
    return this.#_ElapsedTime;
  }

  static get ElapsedTimeInSeconds() {
    return this.#_ElapsedTime / 1000;
  }
  // #endregion
}
starter/style.css
파일 저장

*,
*::after,
*::before {
  box-sizing: border-box;
}

:root {
  font-size: 12px;
  --color-text: #fff;
  --color-bg: #000;
  --color-link: #fff;
  --color-link-hover: #fff;
  --page-padding: 1.5rem;
}

body {
  margin: 0;
  padding: 0;

  width: 100svw;
  height: 100svh;

  overflow: hidden;
  overscroll-behavior: none;

  background: #F8F7F9;

  font-family: ui-monospace, monospace;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

header {
  /* visibility: hidden; */
}

#THREE-CONTAINER {
  position: absolute;
  top: 0;
  left: 0;

  width: 100%;
  height: 100%;
}

.frame {
  padding: 3rem var(--page-padding) 0;
  display: grid;
  z-index: 1000;
  position: relative;
  grid-row-gap: 1rem;
  grid-column-gap: 2rem;
  pointer-events: none;
  justify-items: start;
  grid-template-columns: auto auto;
  grid-template-areas:
    'title'
    'back'
    'archive'
    'github'
    'demos'
    'tags'
    'sponsor';

  #cdawrap {
    justify-self: start;
    grid-area: sponsor;
  }

  a,
  button {
    pointer-events: auto;
  }

  .frame__title {
    grid-area: title;
    font-size: inherit;
    margin: 0;
  }

  .frame__back {
    grid-area: back;
    justify-self: start;
  }

  .frame__archive {
    grid-area: archive;
    justify-self: start;
  }

  .frame__github {
    grid-area: github;
  }

  .frame__tags {
    grid-area: tags;
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
  }

  .frame__demos {
    grid-area: demos;
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
  }

  @media screen and (min-width: 53em) {
    padding: var(--page-padding);
    height: 100%;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    grid-template-columns: auto auto auto auto 1fr;
    grid-template-rows: auto auto;
    align-content: space-between;
    grid-template-areas:
      'title back github archive demos'
      'tags tags tags sponsor sponsor';

    .frame__tags {
      align-self: end;
    }

    .frame__demos,
    #cdawrap {
      justify-self: end;
      text-align: right;
      max-width: 300px;
    }
  }
}

.content {
  padding: var(--page-padding);
  display: flex;
  flex-direction: column;
  position: absolute;
  top: 0;
  left: 0;

  width: 100%;
  height: 100%;

  @media screen and (min-width: 53em) {
    min-height: 100vh;
    justify-content: center;
    align-items: center;
  }
}
Original author attribution실행 안내·자료
파일 저장

Building an Interactive Image Grid with Three.js
Original author: Samuel Jarry
Published by Codrops. Copyright (c) 2026 Codrops.
License: MIT, pursuant to the publisher's downloadable-demo grant.
Keep CODROPS-MIT-2026.txt and the original project notices with copies.
Third-party media exceptions and credits are recorded separately in the source inventory.
Bundled dependency licenses실행 안내·자료
파일 저장

three@0.174.0 — LICENSE
The MIT License

Copyright © 2010-2025 three.js authors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.