// O motor 3D do kit, em JavaScript puro (o render headless do Remotion não tem WebGL). // Base: o raymarcher do apple-tipografico, reiluminado pra estúdio ESCURO: // · duas tiras de luz de borda atrás do produto (quente à esquerda, fria à direita), presas à câmera, // então a borda acende em qualquer ângulo de órbita; // · uma softbox fraca em cima (a luz de chave), o resto é breu; // · chão polido: cada pixel do chão traça o reflexo do produto, que some com a altura (brilho molhado); // · materiais: anodizado, metal polido, borracha estriada, vidro com tratamento (verde/violeta) e acento. // Determinístico: o mesmo quadro sempre sai igual. export type CenaRM = { n: number; tipo: Int8Array; pos: Float64Array; tam: Float64Array; raio: Float64Array; rot: Float64Array; mat: Int8Array; cor: Float64Array; un: Float64Array; corte: Float64Array; ext: Float64Array; esfera: [number, number, number, number]; chao: number; sombra: number; reflexo: number; acento: [number, number, number]; brilho: number; /** intensidade geral da luz 0..1 (revelar do breu) */ luz: number; /** força das bordas 0..1.5 */ borda: number; }; export type CameraRM = {olho: [number, number, number]; alvo: [number, number, number]; tan: number}; const norm = (x: number, y: number, z: number): [number, number, number] => {const l = Math.hypot(x, y, z); return [x / l, y / l, z / l];}; const LC = norm(-0.1, 1, 0.1); const sst = (a: number, b: number, x: number) => {const t = Math.max(0, Math.min(1, (x - a) / (b - a))); return t * t * (3 - 2 * t);}; export const desenhar = (ctx: CanvasRenderingContext2D, W: number, H: number, c: CenaRM, cam: CameraRM) => { const {n, tipo, pos, tam, raio, rot, mat, cor, un, corte, ext} = c; let ID = -1; // ---------- distância até o produto (e qual peça) ---------- const mapa = (x: number, y: number, z: number): number => { let d = 1e5; ID = -1; for (let i = 0; i < n; i++) { const dx = x - pos[i * 3], dy = y - pos[i * 3 + 1], dz = z - pos[i * 3 + 2]; const k = un[i]; const lb = Math.sqrt(dx * dx + dy * dy + dz * dz) - ext[i]; if (k < 0 ? lb >= -d : lb > d + k) continue; const r = i * 9; const qx = rot[r] * dx + rot[r + 3] * dy + rot[r + 6] * dz; const qy = rot[r + 1] * dx + rot[r + 4] * dy + rot[r + 7] * dz; const qz = rot[r + 2] * dx + rot[r + 5] * dy + rot[r + 8] * dz; const sx = tam[i * 3], sy = tam[i * 3 + 1], sz = tam[i * 3 + 2]; let di: number; const t = tipo[i]; if (t === 1) { // caixa arredondada const rr = raio[i]; const ax = Math.abs(qx) - sx + rr, ay = Math.abs(qy) - sy + rr, az = Math.abs(qz) - sz + rr; const mx = Math.max(ax, 0), my = Math.max(ay, 0), mz = Math.max(az, 0); di = Math.sqrt(mx * mx + my * my + mz * mz) + Math.min(Math.max(ax, Math.max(ay, az)), 0) - rr; } else if (t === 2) { // cápsula cônica const r1 = sx, h = sy, r2 = sz, py = qy + h * 0.5; const b = (r1 - r2) / h, a = Math.sqrt(1 - b * b); const q0 = Math.sqrt(qx * qx + qz * qz); const kk = -b * q0 + a * py; if (kk < 0) di = Math.sqrt(q0 * q0 + py * py) - r1; else if (kk > a * h) di = Math.sqrt(q0 * q0 + (py - h) * (py - h)) - r2; else di = q0 * a + py * b - r1; } else if (t === 3) { // elipsoide const ex = qx / sx, ey = qy / sy, ez = qz / sz; const k0 = Math.sqrt(ex * ex + ey * ey + ez * ez); const fx = qx / (sx * sx), fy = qy / (sy * sy), fz = qz / (sz * sz); const k1 = Math.sqrt(fx * fx + fy * fy + fz * fz); di = (k0 * (k0 - 1)) / Math.max(k1, 1e-6); } else if (t === 4 || t === 6) { // cilindro arredondado (6 = estriado: sulcos em volta, sz = nº de sulcos) const rr = raio[i]; let q0 = Math.sqrt(qx * qx + qz * qz); if (t === 6) {const N = sz, amp = (0.85 * sx) / N; q0 += amp * (0.5 + 0.5 * Math.cos(Math.atan2(qz, qx) * N));} const ax = q0 - sx + rr, ay = Math.abs(qy) - sy + rr; di = Math.min(Math.max(ax, ay), 0) + Math.hypot(Math.max(ax, 0), Math.max(ay, 0)) - rr; if (t === 6) di *= 0.6; } else { // anel const q0 = Math.sqrt(qx * qx + qz * qz) - sx; di = Math.sqrt(q0 * q0 + qy * qy) - sy; } const cs = corte[i * 2]; if (cs !== 0) di = Math.max(di, -cs * (qy - corte[i * 2 + 1])); if (k < 0) {if (-di > d) ID = i; d = Math.max(d, -di);} else if (k > 0) { if (di < d) ID = i; const hh = Math.max(k - Math.abs(d - di), 0) / k; d = Math.min(d, di) - hh * hh * k * 0.25; } else if (di < d) {d = di; ID = i;} } return d; }; const [ex, ey, ez, er] = c.esfera; const esfera = (ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, rr = er): [number, number] | null => { const cx = ox - ex, cy = oy - ey, cz = oz - ez; const b = cx * dx + cy * dy + cz * dz, cc = cx * cx + cy * cy + cz * cz - rr * rr, h = b * b - cc; if (h < 0) return null; const s = Math.sqrt(h); return [-b - s, -b + s]; }; const sombra = (ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, k: number, passos: number) => { const b = esfera(ox, oy, oz, dx, dy, dz); if (!b || b[1] < 0) return 1; let res = 1, t = Math.max(b[0], 0.012); for (let i = 0; i < passos; i++) { const h = mapa(ox + dx * t, oy + dy * t, oz + dz * t); res = Math.min(res, (k * h) / t); t += Math.min(Math.max(h, 0.014), 0.2); if (res < 0.003 || t > b[1]) break; } return Math.max(0, Math.min(1, res)); }; // ---------- câmera ---------- const [cx0, cy0, cz0] = cam.olho; let [fx, fy, fz] = [cam.alvo[0] - cx0, cam.alvo[1] - cy0, cam.alvo[2] - cz0]; [fx, fy, fz] = norm(fx, fy, fz); let [rx, ry, rz] = norm(-fz, 0, fx); if (!isFinite(rx)) [rx, ry, rz] = [1, 0, 0]; const ux = ry * fz - rz * fy, uy = rz * fx - rx * fz, uz = rx * fy - ry * fx; const meio = 0.5 * H; const raioDe = (px: number, py: number): [number, number, number] => { const u = ((px - 0.5 * W) / meio) * cam.tan, v = ((0.5 * H - py) / meio) * cam.tan; return norm(fx + u * rx + v * ux, fy + u * ry + v * uy, fz + u * rz + v * uz); }; // luzes presas à câmera: (direita, cima, frente-pra-cena) const daCam = (a: number, b: number, cc: number) => norm(a * rx + b * ux + cc * fx, a * ry + b * uy + cc * fy, a * rz + b * uz + cc * fz); const LK = daCam(-0.45, 0.85, -0.3); // chave: em cima, à esquerda, do lado da câmera const LQ = daCam(-0.92, 0.2, 0.55); // borda quente (atrás, esquerda) const LF = daCam(0.92, 0.12, 0.6); // borda fria (atrás, direita) const Q = [1.0, 0.9, 0.8], F = [0.82, 0.9, 1.0]; const luz = c.luz, bordaK = c.borda; // ambiente (o que o metal, o vidro e o anodizado refletem): breu + duas tiras verticais + softbox no teto const env = (dx: number, dy: number, dz: number, out: number[]) => { const a = dx * rx + dy * ry + dz * rz, b = dx * ux + dy * uy + dz * uz, cc = dx * fx + dy * fy + dz * fz; const fi = Math.atan2(a, cc); // 0 = atrás do produto (longe da câmera), ±π = a câmera const alt = sst(-0.55, -0.3, b) * (1 - sst(0.75, 0.92, b)); const tq = (1 - sst(0.07, 0.13, Math.abs(fi + 1.75))) * alt * 2.6 * bordaK; const tf = (1 - sst(0.07, 0.13, Math.abs(fi - 1.8))) * alt * 2.4 * bordaK; const teto = sst(0.55, 0.75, b) * (1 - sst(0.16, 0.24, Math.abs(a))) * 0.5; // softbox retangular no teto // tira de frente (à esquerda da câmera): o risco longo que corre no vidro e nas faces const tfr = (1 - sst(0.035, 0.07, Math.abs(fi + 2.62))) * sst(-0.4, -0.1, b) * (1 - sst(0.5, 0.7, b)) * 1.3 * bordaK; const chaoE = 0.012; out[0] = chaoE + tq * Q[0] + tf * F[0] + teto + tfr; out[1] = chaoE + tq * Q[1] + tf * F[1] + teto + tfr; out[2] = chaoE + tq * Q[2] + tf * F[2] + teto * 1.03 + tfr; }; const E = [0, 0, 0]; // ---------- um raio qualquer: cor em out, devolve a peça (−1 = nada) e a altura do ponto em hy ---------- const out = [0, 0, 0]; let hy = 0; const traçar = (ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, barato: boolean): number => { const b = esfera(ox, oy, oz, dx, dy, dz); out[0] = out[1] = out[2] = 0; if (!b || b[1] < 0) return -1; let t = Math.max(b[0], 0), hit = -1; const passos = barato ? 70 : 160; for (let i = 0; i < passos; i++) { const h = mapa(ox + dx * t, oy + dy * t, oz + dz * t); if (h < 0.00025 * t + 0.0002) {hit = ID; break;} t += h * 0.95; if (t > b[1]) break; } if (hit < 0) return -1; const px3 = ox + dx * t, py3 = oy + dy * t, pz3 = oz + dz * t; hy = py3; const e = 0.0007; const a1 = mapa(px3 + e, py3 - e, pz3 - e), a2 = mapa(px3 - e, py3 - e, pz3 + e), a3 = mapa(px3 - e, py3 + e, pz3 - e), a4 = mapa(px3 + e, py3 + e, pz3 + e); let [nx, ny, nz] = norm(a1 - a2 - a3 + a4, -a1 - a2 + a3 + a4, -a1 + a2 - a3 + a4); if (!isFinite(nx)) {nx = 0; ny = 1; nz = 0;} const m = mat[hit]; const br = cor[hit * 3], bg = cor[hit * 3 + 1], bb = cor[hit * 3 + 2]; const sh = barato ? 1 : sombra(px3 + nx * 0.004, py3 + ny * 0.004, pz3 + nz * 0.004, LK[0], LK[1], LK[2], 10, 26); let ao = 1; if (!barato) {let o = 0, s = 1; for (let i = 0; i < 3; i++) {const hh = 0.012 + 0.05 * i; o += (hh - mapa(px3 + nx * hh, py3 + ny * hh, pz3 + nz * hh)) * s; s *= 0.75;} ao = Math.max(0, Math.min(1, 1 - 2.2 * o));} const ndv = Math.max(0, Math.min(1, -(nx * dx + ny * dy + nz * dz))); const fre = Math.pow(1 - ndv, 4); const nk = nx * LK[0] + ny * LK[1] + nz * LK[2]; const dif = Math.max(0, nk) * sh; const [hx, hy2, hz] = norm(LK[0] - dx, LK[1] - dy, LK[2] - dz); const nh = Math.max(0, nx * hx + ny * hy2 + nz * hz); // borda: a luz de trás lambendo a silhueta const rimQ = Math.max(0, nx * LQ[0] + ny * LQ[1] + nz * LQ[2]), rimF = Math.max(0, nx * LF[0] + ny * LF[1] + nz * LF[2]); const silh = Math.pow(1 - ndv, 2.2) * bordaK; const rim0 = silh * rimQ * Q[0] + silh * rimF * F[0], rim1 = silh * rimQ * Q[1] + silh * rimF * F[1], rim2 = silh * rimQ * Q[2] + silh * rimF * F[2]; const dn = dx * nx + dy * ny + dz * nz; env(dx - 2 * dn * nx, dy - 2 * dn * ny, dz - 2 * dn * nz, E); let r: number, g: number, bl: number; if (m === 0) { // anodizado: fosco acetinado, reflexo borrado const k = 0.55 * dif + 0.05, sp = 0.32 * Math.pow(nh, 46) * sh, rf = 0.05 + 0.55 * fre; r = (br * k + sp + E[0] * rf) * ao + rim0 * 0.85; g = (bg * k + sp + E[1] * rf) * ao + rim1 * 0.85; bl = (bb * k + sp + E[2] * rf) * ao + rim2 * 0.85; } else if (m === 1) { // metal polido const sp = 1.4 * Math.pow(nh, 140) * sh, aoK = 0.6 + 0.4 * ao; r = (br * (E[0] * 0.95 + 0.04 + 0.12 * dif) + sp) * aoK + rim0 * 0.4; g = (bg * (E[1] * 0.95 + 0.04 + 0.12 * dif) + sp) * aoK + rim1 * 0.4; bl = (bb * (E[2] * 0.95 + 0.04 + 0.12 * dif) + sp) * aoK + rim2 * 0.4; } else if (m === 2) { // borracha estriada const k = 0.4 * dif + 0.03, sp = 0.12 * Math.pow(nh, 18) * sh; r = (br * k + sp + E[0] * fre * 0.12) * ao + rim0 * 0.55; g = (bg * k + sp + E[1] * fre * 0.12) * ao + rim1 * 0.55; bl = (bb * k + sp + E[2] * fre * 0.12) * ao + rim2 * 0.55; } else if (m === 3) { // acento: anel que acende const k = 0.3 + 0.9 * c.brilho; r = c.acento[0] * k * (0.8 + 0.5 * dif) + E[0] * fre * 0.3 + rim0 * 0.3; g = c.acento[1] * k * (0.8 + 0.5 * dif) + E[1] * fre * 0.3 + rim1 * 0.3; bl = c.acento[2] * k * (0.8 + 0.5 * dif) + E[2] * fre * 0.3 + rim2 * 0.3; } else { // vidro: quase preto, espelho no ângulo, tratamento verde→violeta const rf = 0.07 + 0.9 * fre, sp = 2.2 * Math.pow(nh, 260); const cv = 1 - ndv; // 0 de frente, 1 rasante const tr = 0.16 * (1 - cv) + 0.5 * cv, tg = 0.34 * (1 - cv) + 0.12 * cv, tb = 0.22 * (1 - cv) + 0.62 * cv; const film = 0.2 * Math.pow(Math.sin(ndv * 3.1), 2); r = br + E[0] * rf + sp + tr * film + rim0 * 0.25; g = bg + E[1] * rf + sp + tg * film + rim1 * 0.25; bl = bb + E[2] * rf + sp + tb * film + rim2 * 0.25; } out[0] = Math.min(1, r * luz); out[1] = Math.min(1, g * luz); out[2] = Math.min(1, bl * luz); return hit; }; // ---------- 1) chão: sombra de contato + reflexo do produto, numa grade de 1/2 ---------- const G = 2, gw = Math.ceil(W / G) + 2, gh = Math.ceil(H / G) + 2; const somA = new Float32Array(gw * gh), refl = new Float32Array(gw * gh * 4); if (c.sombra > 0 || c.reflexo > 0) { const raioChao2 = er * er * 7; for (let j = 0; j < gh; j++) for (let i = 0; i < gw; i++) { const [dx, dy, dz] = raioDe(i * G, j * G); if (dy >= 0) continue; const tf = (c.chao - cy0) / dy; const x = cx0 + dx * tf, z = cz0 + dz * tf; const qx = x - ex, qz = z - ez; if (qx * qx + qz * qz > raioChao2) continue; const k = j * gw + i; if (c.sombra > 0 && (i % 2 === 0) && (j % 2 === 0)) { let o = 0, s = 1; for (let q = 0; q < 4; q++) {const hh = 0.03 + 0.16 * q; o += Math.max(hh - mapa(x, c.chao + hh, z), 0) * s; s *= 0.72;} const sh = sombra(x, c.chao + 0.002, z, LC[0], LC[1], LC[2], 3.2, 20); const v = c.sombra * Math.max(0, Math.min(1, 0.5 * (1 - sh) + 0.7 * (1 - Math.max(0, Math.min(1, 1 - 1.1 * o))))); somA[k] = v; if (i + 1 < gw) somA[k + 1] = v; if (j + 1 < gh) {somA[k + gw] = v; if (i + 1 < gw) somA[k + gw + 1] = v;} } if (c.reflexo > 0) { const id = traçar(x, c.chao + 0.0005, z, dx, -dy, dz, true); if (id >= 0) { const alto = Math.max(0, hy - c.chao); const a = c.reflexo * Math.exp(-alto * 1.25) * (0.55 + 0.45 * (1 + dy)); refl[k * 4] = out[0] * a; refl[k * 4 + 1] = out[1] * a; refl[k * 4 + 2] = out[2] * a; refl[k * 4 + 3] = a; } } } } const amostra = (arr: Float32Array, canais: number, ch: number, x: number, y: number) => { const gx = x / G, gy = y / G, i = Math.min(gw - 2, Math.floor(gx)), j = Math.min(gh - 2, Math.floor(gy)), tx = gx - i, ty = gy - j; const A = arr[(j * gw + i) * canais + ch], B = arr[(j * gw + i + 1) * canais + ch], C = arr[((j + 1) * gw + i) * canais + ch], D = arr[((j + 1) * gw + i + 1) * canais + ch]; return (A * (1 - tx) + B * tx) * (1 - ty) + (C * (1 - tx) + D * tx) * ty; }; // ---------- 2) o produto, um raio por pixel ---------- const img = ctx.createImageData(W, H); const px = img.data; const ids = new Int16Array(W * H).fill(-1); const cor4 = new Float32Array(W * H * 3); const proj = (x: number, y: number, z: number): [number, number] | null => { const vx = x - cx0, vy = y - cy0, vz = z - cz0; const zc = vx * fx + vy * fy + vz * fz; if (zc <= 0.01) return null; return [0.5 * W + ((vx * rx + vy * ry + vz * rz) / zc / cam.tan) * meio, 0.5 * H - ((vx * ux + vy * uy + vz * uz) / zc / cam.tan) * meio]; }; let x0 = 0, x1 = W, y0 = 0, y1 = H; const pc = proj(ex, ey, ez); const dist = Math.hypot(ex - cx0, ey - cy0, ez - cz0); if (pc && dist > er * 1.05) { const rp = (er / Math.sqrt(dist * dist - er * er)) / cam.tan * meio * 1.08 + 2; x0 = Math.max(0, Math.floor(pc[0] - rp)); x1 = Math.min(W, Math.ceil(pc[0] + rp)); y0 = Math.max(0, Math.floor(pc[1] - rp)); y1 = Math.min(H, Math.ceil(pc[1] + rp)); } for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { const [dx, dy, dz] = raioDe(x + 0.5, y + 0.5); const id = traçar(cx0, cy0, cz0, dx, dy, dz, false); if (id < 0) continue; const k = y * W + x; ids[k] = id; cor4[k * 3] = out[0]; cor4[k * 3 + 1] = out[1]; cor4[k * 3 + 2] = out[2]; } // ---------- 3) bordas: 4 raios por pixel onde a peça muda ---------- const SUB = [[0.125, 0.375], [0.375, -0.125], [-0.125, -0.375], [-0.375, 0.125]]; const borda = new Uint8Array(W * H); for (let y = Math.max(0, y0 - 1); y < Math.min(H, y1 + 1); y++) for (let x = Math.max(0, x0 - 1); x < Math.min(W, x1 + 1); x++) { const k = y * W + x, a = ids[k]; if ((x + 1 < W && ids[k + 1] !== a) || (y + 1 < H && ids[k + W] !== a)) {borda[k] = 1; if (x + 1 < W) borda[k + 1] = 1; if (y + 1 < H) borda[k + W] = 1;} } // fundo de um ponto: reflexo sobre a sombra (pré-multiplicado) const fundo = (x: number, y: number, o: number[]) => { const s = amostra(somA, 1, 0, x, y); const ra = amostra(refl, 4, 3, x, y); o[0] = amostra(refl, 4, 0, x, y); o[1] = amostra(refl, 4, 1, x, y); o[2] = amostra(refl, 4, 2, x, y); o[3] = ra + s * (1 - ra); }; const F4 = [0, 0, 0, 0]; for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const k = y * W + x; let r: number, g: number, b: number, a: number; if (borda[k]) { r = g = b = a = 0; for (const [sx, sy] of SUB) { const [dx, dy, dz] = raioDe(x + 0.5 + sx, y + 0.5 + sy); const id = traçar(cx0, cy0, cz0, dx, dy, dz, false); if (id >= 0) {r += out[0]; g += out[1]; b += out[2]; a += 1;} else {fundo(x + 0.5 + sx, y + 0.5 + sy, F4); r += F4[0]; g += F4[1]; b += F4[2]; a += F4[3];} } r /= 4; g /= 4; b /= 4; a /= 4; } else if (ids[k] >= 0) {r = cor4[k * 3]; g = cor4[k * 3 + 1]; b = cor4[k * 3 + 2]; a = 1;} else {fundo(x + 0.5, y + 0.5, F4); a = F4[3]; if (a <= 0.002) continue; r = F4[0]; g = F4[1]; b = F4[2];} if (a <= 0.0001) continue; const p = k * 4; px[p] = Math.min(255, (r / a) * 255); px[p + 1] = Math.min(255, (g / a) * 255); px[p + 2] = Math.min(255, (b / a) * 255); px[p + 3] = Math.min(255, a * 255); } ctx.putImageData(img, 0, 0); };