πŸͺ© Color Plate Rotation

BamgasiJMΒ·2026λ…„ 6μ›” 26일

WebGL

λͺ©λ‘ 보기
13/14
post-thumbnail

πŸ“ index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WebGL2 Shader Playground</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div id="errBox"></div>

    <div class="container">
      <canvas id="canvas" width="640" height="640"></canvas>

      <div class="controls-panel">
        <div class="control-group color-group">
          <div class="color-display">
            <div id="colorSwatch"></div>
          </div>
          <input
            type="color"
            id="colorPicker"
            value="#ff6b6b"
            class="color-input"
          />
        </div>

        <div class="control-group rotation-group">
          <div class="rotation-header">
            <label for="rotSlider">Rotation</label>
            <span id="rotVal" class="rotation-value">0Β°</span>
          </div>
          <input
            type="range"
            id="rotSlider"
            min="0"
            max="360"
            value="0"
            class="slider"
          />
        </div>
      </div>
    </div>

    <script src="main.js"></script>
  </body>
</html>

πŸ“ style.css

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  background: linear-gradient(135deg, #d4d4ed 0%, #1b1b25 100%);
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  font-family:
    -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  color: #e8e8f0;
}

.container {
  display: flex;
  flex-direction: column;
  gap: 32px;
  align-items: center;
}

canvas {
  display: block;
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 12px;
  box-shadow:
    0 8px 32px rgba(0, 0, 0, 0.3),
    inset 0 1px 0 rgba(255, 255, 255, 0.05);
  background: #0a0a14;
}

/* ── 컨트둀 νŒ¨λ„ ── */
.controls-panel {
  display: flex;
  gap: 48px;
  padding: 32px 40px;
  background: rgba(255, 255, 255, 0.02);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 16px;
  backdrop-filter: blur(10px);
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}

.control-group {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

/* ── 색상 컨트둀 ── */
.color-group {
  align-items: center;
  gap: 20px;
}

.color-display {
  position: relative;
}

#colorSwatch {
  width: 80px;
  height: 80px;
  border-radius: 12px;
  background: #ff6b6b;
  box-shadow:
    0 8px 24px rgba(255, 255, 255, 0.3),
    inset 0 1px 0 rgba(255, 255, 255, 0.2);
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  border: 1px solid rgba(255, 255, 255, 0.1);
}

#colorSwatch:hover {
  transform: scale(1.05);
  box-shadow:
    0 12px 32px rgba(255, 107, 107, 0.4),
    inset 0 1px 0 rgba(255, 255, 255, 0.2);
}

.color-input {
  width: 56px;
  height: 56px;
  border: 1px solid rgba(255, 255, 255, 0.15);
  border-radius: 10px;
  cursor: pointer;
  opacity: 0;
  position: absolute;
  pointer-events: auto;
}

/* ── νšŒμ „ 컨트둀 ── */
.rotation-group {
  min-width: 240px;
}

.rotation-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.rotation-header label {
  font-size: 13px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 1.2px;
  color: #a8a8b8;
}

.rotation-value {
  font-size: 18px;
  font-weight: 600;
  color: #ffffff;
  min-width: 48px;
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* ── μŠ¬λΌμ΄λ” μ»€μŠ€ν…€ μŠ€νƒ€μΌ ── */
.slider {
  width: 100%;
  height: 6px;
  border-radius: 3px;
  background: linear-gradient(
    to right,
    rgba(255, 255, 255, 0.3),
    rgba(255, 255, 255, 0.2)
  );
  outline: none;
  -webkit-appearance: none;
  appearance: none;
  cursor: pointer;
}

/* webkit 썸 μŠ€νƒ€μΌ */
.slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
  cursor: pointer;
  box-shadow:
    0 4px 12px rgba(255, 255, 255, 0.5),
    inset 0 1px 0 rgba(255, 255, 255, 0.6);
  border: 1px solid rgba(255, 255, 255, 0.4);
  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  transform: translateY(-35%);
}

.slider::-webkit-slider-thumb:hover {
  transform: scale(1.2);
  box-shadow:
    0 6px 20px rgba(255, 255, 255, 0.6),
    inset 0 1px 0 rgba(255, 255, 255, 0.6);
}

.slider::-webkit-slider-thumb:active {
  transform: scale(1.15);
  box-shadow:
    0 4px 12px rgba(255, 255, 255, 0.5),
    inset 0 1px 0 rgba(255, 255, 255, 0.6);
}

/* moz 썸 μŠ€νƒ€μΌ */
.slider::-moz-range-thumb {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
  cursor: pointer;
  box-shadow:
    0 4px 12px rgba(255, 255, 255, 0.5),
    inset 0 1px 0 rgba(255, 255, 255, 0.6);
  border: 1px solid rgba(255, 255, 255, 0.4);
  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}

.slider::-moz-range-thumb:hover {
  transform: scale(1.2);
  box-shadow:
    0 6px 20px rgba(255, 255, 255, 0.6),
    inset 0 1px 0 rgba(255, 255, 255, 0.6);
}

.slider::-webkit-slider-runnable-track {
  background: linear-gradient(
    to right,
    rgba(255, 255, 255, 0.3),
    rgba(255, 255, 255, 0.2)
  );
  height: 6px;
  border-radius: 3px;
  border: none;
}

/* ── μ—λŸ¬ λ°•μŠ€ ── */
#errBox {
  display: none;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  background: rgba(100, 30, 30, 0.95);
  color: #ff9999;
  padding: 16px 24px;
  z-index: 999;
  font-size: 13px;
  white-space: pre-wrap;
  max-height: 50vh;
  overflow: auto;
  border-bottom: 2px solid rgba(255, 107, 107, 0.5);
  line-height: 1.5;
  backdrop-filter: blur(8px);
}

/* ── λ ˆμŠ€ν°μ‹œλΈŒ ── */
@media (max-width: 768px) {
  .container {
    gap: 20px;
    padding: 16px;
  }

  canvas {
    width: 100%;
    max-width: 400px;
    height: auto;
  }

  .controls-panel {
    flex-direction: column;
    gap: 28px;
    padding: 24px 28px;
    width: 100%;
    max-width: 400px;
  }

  .rotation-group {
    min-width: auto;
  }
}

/* ── 포컀슀 μŠ€νƒ€μΌ ── */
.slider:focus {
  outline: 2px solid rgba(255, 255, 255, 0.3);
  outline-offset: 2px;
}

/* ── κ°μ†Œλœ λͺ¨μ…˜ ── */
@media (prefers-reduced-motion: reduce) {
  #colorSwatch,
  .slider::-webkit-slider-thumb,
  .slider::-moz-range-thumb {
    transition: none;
  }

  #colorSwatch:hover {
    transform: none;
  }

  .slider::-webkit-slider-thumb:hover {
    transform: none;
  }
}

πŸ“ main.js

// ── 셰이더 μ†ŒμŠ€ λ‘œλ“œ ──
async function loadShader(url) {
    const response = await fetch(url);
    if (!response.ok) {
        throw new Error(`[X] Shader Loading Failed: ${url}`);
    }
    return response.text();
}

function showError(msg) {
    const box = document.getElementById('errBox');
    box.style.display = 'block';
    box.textContent = msg;
}

// ── 셰이더 컴파일 ──
function compileShader(gl, src, type, name) {
    const s = gl.createShader(type);
    gl.shaderSource(s, src);
    gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
        const err = gl.getShaderInfoLog(s);
        showError(`[X] Shader compile failed (${name}):\n\n${err}\n\nSOURCE:\n${src}`);
        gl.deleteShader(s);
        return null;
    }
    return s;
}

// ── ν”„λ‘œκ·Έλž¨ 생성 ──
function createProgram(gl, vs, fs) {
    const prog = gl.createProgram();
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
        showError(`[X] Program link failed:\n\n${gl.getProgramInfoLog(prog)}`);
        throw new Error('Program link failed');
    }
    return prog;
}

// ── 투영 ν–‰λ ¬ ──
function perspective(fov, aspect, near, far) {
    const f = 1.0 / Math.tan(fov / 2);
    const nf = 1 / (near - far);
    return new Float32Array([
        f / aspect, 0, 0, 0,
        0, f, 0, 0,
        0, 0, (far + near) * nf, -1,
        0, 0, 2 * far * near * nf, 0,
    ]);
}

// ── λ·° ν–‰λ ¬ ──
function lookAt(eye, center, up) {
    let zx = eye[0] - center[0], zy = eye[1] - center[1], zz = eye[2] - center[2];
    let l = 1 / Math.hypot(zx, zy, zz);
    const z = [zx * l, zy * l, zz * l];
    const x0 = up[1] * z[2] - up[2] * z[1], x1 = up[2] * z[0] - up[0] * z[2], x2 = up[0] * z[1] - up[1] * z[0];
    l = 1 / Math.hypot(x0, x1, x2);
    const x = [x0 * l, x1 * l, x2 * l];
    const y = [z[1] * x[2] - z[2] * x[1], z[2] * x[0] - z[0] * x[2], z[0] * x[1] - z[1] * x[0]];
    return new Float32Array([
        x[0], y[0], z[0], 0,
        x[1], y[1], z[1], 0,
        x[2], y[2], z[2], 0,
        -(x[0] * eye[0] + x[1] * eye[1] + x[2] * eye[2]),
        -(y[0] * eye[0] + y[1] * eye[1] + y[2] * eye[2]),
        -(z[0] * eye[0] + z[1] * eye[1] + z[2] * eye[2]),
        1,
    ]);
}

// ── Y μΆ• νšŒμ „ ν–‰λ ¬ ──
function rotateY(angle) {
    const c = Math.cos(angle), s = Math.sin(angle);
    return new Float32Array([
        c, 0, -s, 0,
        0, 1, 0, 0,
        s, 0, c, 0,
        0, 0, 0, 1
    ]);
}

// ── 16μ§„μˆ˜ 색상을 RGB둜 λ³€ν™˜ ──
function hexToRgb(hex) {
    const r = parseInt(hex.slice(1, 3), 16) / 255;
    const g = parseInt(hex.slice(3, 5), 16) / 255;
    const b = parseInt(hex.slice(5, 7), 16) / 255;
    return { r, g, b };
}

// ── 메인 μ΄ˆκΈ°ν™” ──
async function init() {
    const canvas = document.getElementById('canvas');
    const gl = canvas.getContext('webgl2', { antialias: true });
    if (!gl) {
        showError('WebGL2 not supported');
        return;
    }

    try {
        // 셰이더 파일 λ‘œλ“œ
        const vertSrc = await loadShader('shaders/vert.glsl');
        const fragSrc = await loadShader('shaders/frag.glsl');

        // 셰이더 컴파일
        const vs = compileShader(gl, vertSrc, gl.VERTEX_SHADER, 'VERTEX');
        if (!vs) return;

        const fs = compileShader(gl, fragSrc, gl.FRAGMENT_SHADER, 'FRAGMENT');
        if (!fs) return;

        const prog = createProgram(gl, vs, fs);
        gl.useProgram(prog);

        // ── μ§€μ˜€λ©”νŠΈλ¦¬ μ„€μ • ──
        const vertices = new Float32Array([
            -0.8, -0.8, 0.0, 0.0, 0.0,
            0.8, -0.8, 0.0, 1.0, 0.0,
            0.8, 0.8, 0.0, 1.0, 1.0,
            -0.8, 0.8, 0.0, 0.0, 1.0,
        ]);
        const indices = new Uint16Array([0, 1, 2, 0, 2, 3]);

        const vao = gl.createVertexArray();
        gl.bindVertexArray(vao);

        const vbo = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

        gl.enableVertexAttribArray(0);
        gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 20, 0);

        gl.enableVertexAttribArray(1);
        gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 20, 12);

        const ebo = gl.createBuffer();
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ebo);
        gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

        gl.bindVertexArray(null);

        // ── μœ λ‹ˆνΌ μœ„μΉ˜ ──
        const locProjection = gl.getUniformLocation(prog, 'uProjection');
        const locView = gl.getUniformLocation(prog, 'uView');
        const locModel = gl.getUniformLocation(prog, 'uModel');
        const locColor = gl.getUniformLocation(prog, 'uColor');

        // ── UI μ—˜λ¦¬λ¨ΌνŠΈ ──
        const colorPicker = document.getElementById('colorPicker');
        const colorSwatch = document.getElementById('colorSwatch');
        const rotSlider = document.getElementById('rotSlider');
        const rotVal = document.getElementById('rotVal');

        // ── 색상 픽컀 λ³€κ²½ 이벀트 ──
        colorPicker.addEventListener('change', (e) => {
            colorSwatch.style.background = e.target.value;
        });

        // 초기 색상 μ„€μ •
        colorSwatch.style.background = colorPicker.value;

        // ── λ Œλ” 루프 ──
        function render() {
            const { r, g, b } = hexToRgb(colorPicker.value);
            const rot = (rotSlider.value / 180) * Math.PI;
            rotVal.textContent = rotSlider.value + 'Β°';

            gl.viewport(0, 0, canvas.width, canvas.height);
            gl.clearColor(0.04, 0.04, 0.08, 1.0);
            gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
            gl.enable(gl.DEPTH_TEST);

            const aspect = canvas.width / canvas.height;
            gl.uniformMatrix4fv(locProjection, false, perspective(Math.PI / 4, aspect, 0.1, 100));
            gl.uniformMatrix4fv(locView, false, lookAt([0, 0, 3], [0, 0, 0], [0, 1, 0]));
            gl.uniformMatrix4fv(locModel, false, rotateY(rot));
            gl.uniform4f(locColor, r, g, b, 1.0);

            gl.bindVertexArray(vao);
            gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);

            requestAnimationFrame(render);
        }
        render();

    } catch (error) {
        showError(`μ΄ˆκΈ°ν™” 였λ₯˜:\n${error.message}`);
        console.error(error);
    }
}

// ── νŽ˜μ΄μ§€ λ‘œλ“œ μ™„λ£Œ ν›„ μ‹œμž‘ ──
init();

πŸ“ vert.glsl

#version 300 es
precision mediump float;

layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec2 aTexCoord;

out vec2 vTexCoord;

uniform mat4 uProjection;
uniform mat4 uView;
uniform mat4 uModel;

void main() {
    gl_Position = uProjection * uView * uModel * vec4(aPosition, 1.0);
    vTexCoord = aTexCoord;
}

πŸ“ frag.glsl

#version 300 es
precision mediump float;

in vec2 vTexCoord;
out vec4 fragColor;

uniform vec4 uColor;

void main() {
    fragColor = uColor;
}
profile
Coding Art with Blender / oF / Processing / p5.js / nannou

0개의 λŒ“κΈ€