# Portal

> A magical standing portal: a chunky carved ring set into a stepped stone plinth, studded with glowing rune gems and filled with an emissive swirling vortex disc. Pick any glow color for teleporters, level exits and warp gates; frame color and size are adjustable.

![Portal preview](https://codels.download/m/portal.png)

Multi-view sheet (3/4, front, side, top): https://codels.download/m/portal.sheet.png

## Facts

- Category: Game Kit › Markers & Goals
- Size (W × H × D): 3.13 × 2.89 × 1.10 m — units are meters, +Y up, front faces +Z
- Origin: center of the base (y = 0)
- Triangles: 1,148 · Meshes: 16 · Materials: 5
- Animations: 'idle' (loop) — see "Animations" below
- License: CC0-1.0 (Codels built-in)
- Tags: gate, portal, teleporter, vortex, cartoon, low-poly, stylized, fantasy, sci-fi, adventure, party-game, platformer, rpg, goal, interactable, landmark, energy, stone, round, animated, emissive, mobile-friendly, parametric, color-gray, color-purple, size-large, poly-low
- Page: https://codels.download/models/portal

## Files

| Format | URL |
|---|---|
| three.js ES module (`createPortal()`) | https://codels.download/m/portal.js |
| React Three Fiber component (`<Portal />`) | https://codels.download/m/portal.jsx |
| Instancing module (`createPortalInstances(count, place)`, one draw call per material) | https://codels.download/m/portal.instanced.js |
| Standalone HTML demo | https://codels.download/m/portal.html |
| glTF binary | https://codels.download/m/portal.glb |
| glTF binary, Meshopt-compressed (2–3× smaller; GLTFLoader needs `setMeshoptDecoder(MeshoptDecoder)`) | https://codels.download/m/portal.glb?compress=meshopt |
| glTF JSON | https://codels.download/m/portal.gltf |
| OBJ (vertex colors) + MTL | https://codels.download/m/portal.obj · https://codels.download/m/portal.mtl |
| STL | https://codels.download/m/portal.stl |
| CodelSpec JSON | https://codels.download/m/portal.json |

## Parameters

Append them as query parameters to any URL above to get a customized variant (e.g. `?glow=...`).

| Param | Type | Range / options | Default | Current |
|---|---|---|---|---|
| `glow` | color | #rrggbb | #8c5cff | #8c5cff |
| `frame` | color | #rrggbb | #6c6a82 | #6c6a82 |
| `size` | number | 1.5 – 4 | 2.5 | 2.5 |

## Animations

The model (the .js module and the .glb/.gltf files alike) carries THREE.AnimationClip objects in `model.animations` (glTF: `gltf.animations`). The static model is the rest pose.

```js
const mixer = new THREE.AnimationMixer(model);
mixer.clipAction(THREE.AnimationClip.findByName(model.animations, 'idle')).play(); // loop
// every frame: mixer.update(deltaSeconds);
```

## Parts

- plinthBase (box · plinth)
- plinthTop (box · frame)
- footLeft (extrude · plinth)
- footRight (extrude · plinth)
- ring (torus · frame)
- rune1 (octahedron · gem)
- rune2 (octahedron · gem)
- rune3 (octahedron · gem)
- rune4 (octahedron · gem)
- rune5 (octahedron · gem)
- rune8 (octahedron · gem)
- vortex (cylinder · disc)
- vortexSwirl (group)
  - swirl1 (extrude · swirl)
  - swirl2 (extrude · swirl)
  - swirl3 (extrude · swirl)
  - core (cylinder · swirl)

## How to use

Save the code below as `portal.js`, then:

```js
import { createPortal } from './portal.js';
const model = createPortal();
scene.add(model);
```

## Code

```js
/**
 * Portal — Codels 3D model library (https://codels.download/models/portal)
 * License: CC0-1.0 · Units: meters · Y-up · Front: +Z · Origin: center of the base (y = 0)
 * Triangles: 1,148 · Size (W × H × D): 3.13 × 2.89 × 1.10 m
 *
 * Usage:
 *   import { createPortal } from './portal.js';
 *   
 *   const model = createPortal();
 *   model.position.set(0, 0, 0);
 *   scene.add(model);
 *   
 *   // Animations: idle
 *   const mixer = new THREE.AnimationMixer(model);
 *   mixer.clipAction(THREE.AnimationClip.findByName(model.animations, 'idle')).play();
 *   // in your render loop: mixer.update(clock.getDelta());
 */
import * as THREE from 'three';

const deg = THREE.MathUtils.degToRad;

// Extrude a 2D outline along Z (centered). bevel = [thickness, size, segments].
function extrude(outline, depth, holes = [], bevel = null) {
  const shape = new THREE.Shape(outline.map(([x, y]) => new THREE.Vector2(x, y)));
  for (const hole of holes) shape.holes.push(new THREE.Path(hole.map(([x, y]) => new THREE.Vector2(x, y))));
  const options = bevel
    ? { depth, bevelEnabled: true, bevelThickness: bevel[0], bevelSize: bevel[1], bevelSegments: bevel[2] }
    : { depth, bevelEnabled: false };
  return new THREE.ExtrudeGeometry(shape, options).translate(0, 0, -depth / 2);
}

// Build animation clips from simple descriptions (Codels). Play them with THREE.AnimationMixer.
function codelsClips(THREE, entries) {
  var TAU = Math.PI * 2;
  var DEG = Math.PI / 180;
  var AX = { x: new THREE.Vector3(1, 0, 0), y: new THREE.Vector3(0, 1, 0), z: new THREE.Vector3(0, 0, 1) };
  var periodOf = function (a) { return a.type === 'spin' ? 360 / Math.abs(a.speed) : a.period; };
  var clips = [];

  // loops → 'idle'
  var loops = [];
  entries.forEach(function (e) {
    var l = e.anim.filter(function (a) { return a.type !== 'hinge'; });
    if (l.length) loops.push({ target: e.target, anim: l });
  });
  if (loops.length) {
    var periods = [];
    loops.forEach(function (e) { e.anim.forEach(function (a) { periods.push(periodOf(a)); }); });
    var longest = Math.max.apply(null, periods);
    var D = longest;
    var bestErr = Infinity;
    for (var m = 1; m <= 6 && bestErr > 0.01; m++) {
      var err = 0;
      periods.forEach(function (p) {
        var n = Math.max(1, Math.round((longest * m) / p));
        err = Math.max(err, Math.abs((longest * m) / n - p) / p);
      });
      if (err < bestErr - 1e-9) { bestErr = err; D = longest * m; }
    }
    var cycles = function (a) { return Math.max(1, Math.round(D / periodOf(a))); };
    var steps = 24;
    loops.forEach(function (e) { e.anim.forEach(function (a) { steps = Math.max(steps, cycles(a) * 24); }); });
    steps = Math.min(steps, 720);
    var tracks = [];
    loops.forEach(function (e) {
      var o = e.target;
      var q0 = o.quaternion.clone(), p0 = o.position.clone(), s0 = o.scale.clone();
      var q = new THREE.Quaternion(), turn = new THREE.Quaternion(), p = new THREE.Vector3(), s = new THREE.Vector3();
      var has = function (type) { return e.anim.some(function (a) { return a.type === type; }); };
      var turns = has('spin') || has('swing'), moves = has('bob'), grows = has('pulse');
      var times = [], qv = [], pv = [], sv = [];
      for (var k = 0; k <= steps; k++) {
        var t = (D * k) / steps;
        q.copy(q0); p.copy(p0); s.copy(s0);
        e.anim.forEach(function (a) {
          var n = cycles(a);
          var w = Math.sin(TAU * ((n * t) / D + (a.phase || 0)));
          if (a.type === 'spin') q.multiply(turn.setFromAxisAngle(AX[a.axis], (a.speed < 0 ? -TAU : TAU) * ((n * t) / D)));
          else if (a.type === 'swing') q.multiply(turn.setFromAxisAngle(AX[a.axis], a.angle * DEG * w));
          else if (a.type === 'bob') p.addScaledVector(AX[a.axis], a.distance * w);
          else if (a.type === 'pulse') s.multiplyScalar(1 + a.amount * w);
        });
        times.push(t);
        if (turns) qv.push(q.x, q.y, q.z, q.w);
        if (moves) pv.push(p.x, p.y, p.z);
        if (grows) sv.push(s.x, s.y, s.z);
      }
      if (turns) tracks.push(new THREE.QuaternionKeyframeTrack(o.uuid + '.quaternion', times, qv));
      if (moves) tracks.push(new THREE.VectorKeyframeTrack(o.uuid + '.position', times, pv));
      if (grows) tracks.push(new THREE.VectorKeyframeTrack(o.uuid + '.scale', times, sv));
    });
    clips.push(new THREE.AnimationClip('idle', D, tracks));
  }

  // hinge moves → one-shot clips ('open' …)
  var shots = {};
  entries.forEach(function (e) {
    e.anim.forEach(function (a) {
      if (a.type !== 'hinge') return;
      var name = a.clip || 'open';
      (shots[name] = shots[name] || []).push({ target: e.target, a: a });
    });
  });
  Object.keys(shots).forEach(function (name) {
    var tracks = [];
    var length = 0;
    shots[name].forEach(function (h) {
      var o = h.target, q0 = o.quaternion.clone(), q = new THREE.Quaternion(), turn = new THREE.Quaternion();
      var n = Math.max(4, Math.ceil(Math.abs(h.a.angle) / 10));
      var times = [], qv = [];
      for (var k = 0; k <= n; k++) {
        var f = k / n;
        var eased = f * f * (3 - 2 * f);
        q.copy(q0).multiply(turn.setFromAxisAngle(AX[h.a.axis], h.a.angle * DEG * eased));
        times.push(h.a.duration * f);
        qv.push(q.x, q.y, q.z, q.w);
      }
      length = Math.max(length, h.a.duration);
      tracks.push(new THREE.QuaternionKeyframeTrack(o.uuid + '.quaternion', times, qv));
    });
    clips.push(new THREE.AnimationClip(name, length, tracks));
  });
  return clips;
}

export function createPortal() {
  const root = new THREE.Group();
  root.name = 'Portal';

  // Materials
  const frameMat = new THREE.MeshStandardMaterial({ name: 'frame', color: 0x6c6a82, roughness: 0.85, flatShading: true });
  const plinthMat = new THREE.MeshStandardMaterial({ name: 'plinth', color: 0x4f4e61, roughness: 0.9, flatShading: true });
  const discMat = new THREE.MeshStandardMaterial({ name: 'disc', color: 0x613eb1, roughness: 0.4, emissive: 0x8c5cff, emissiveIntensity: 0.75 });
  const swirlMat = new THREE.MeshStandardMaterial({ name: 'swirl', color: 0xcbb6ff, roughness: 0.3, emissive: 0xc0a5ff, emissiveIntensity: 1.1, flatShading: true });
  const gemMat = new THREE.MeshStandardMaterial({ name: 'gem', color: 0xaf8dff, roughness: 0.2, emissive: 0x8c5cff, emissiveIntensity: 1.3, flatShading: true });

  // Shared geometries
  const extrudeGeo = extrude([[-0.28, 0], [0.28, 0], [0.15, 0.475], [-0.15, 0.475]], 0.455);
  const octahedronGeo = new THREE.OctahedronGeometry(0.126, 0);

  // Parts
  const plinthBase = new THREE.Mesh(new THREE.BoxGeometry(3.125, 0.12, 1.1), plinthMat);
  plinthBase.name = 'plinthBase';
  plinthBase.position.set(0, 0.06, 0);
  root.add(plinthBase);
  const plinthTop = new THREE.Mesh(new THREE.BoxGeometry(2.625, 0.121, 0.8), frameMat);
  plinthTop.name = 'plinthTop';
  plinthTop.position.set(0, 0.1795, 0);
  root.add(plinthTop);
  const footLeft = new THREE.Mesh(extrudeGeo, plinthMat);
  footLeft.name = 'footLeft';
  footLeft.position.set(-0.775, 0.22, 0);
  footLeft.rotation.set(0, 0, deg(-36));
  root.add(footLeft);
  const footRight = new THREE.Mesh(extrudeGeo, plinthMat);
  footRight.name = 'footRight';
  footRight.position.set(0.775, 0.22, 0);
  footRight.rotation.set(0, 0, deg(36));
  root.add(footRight);
  const ring = new THREE.Mesh(new THREE.TorusGeometry(1.25, 0.175, 6, 36), frameMat);
  ring.name = 'ring';
  ring.position.set(0, 1.4637, 0);
  root.add(ring);
  const rune1 = new THREE.Mesh(octahedronGeo, gemMat);
  rune1.name = 'rune1';
  rune1.position.set(1.1548, 1.9421, 0);
  rune1.rotation.set(0, 0, deg(22.5));
  rune1.scale.set(1, 1, 1.9);
  root.add(rune1);
  const rune2 = new THREE.Mesh(octahedronGeo, gemMat);
  rune2.name = 'rune2';
  rune2.position.set(0.4784, 2.6186, 0);
  rune2.rotation.set(0, 0, deg(67.5));
  rune2.scale.set(1, 1, 1.9);
  root.add(rune2);
  const rune3 = new THREE.Mesh(octahedronGeo, gemMat);
  rune3.name = 'rune3';
  rune3.position.set(-0.4784, 2.6186, 0);
  rune3.rotation.set(0, 0, deg(112.5));
  rune3.scale.set(1, 1, 1.9);
  root.add(rune3);
  const rune4 = new THREE.Mesh(octahedronGeo, gemMat);
  rune4.name = 'rune4';
  rune4.position.set(-1.1548, 1.9421, 0);
  rune4.rotation.set(0, 0, deg(157.5));
  rune4.scale.set(1, 1, 1.9);
  root.add(rune4);
  const rune5 = new THREE.Mesh(octahedronGeo, gemMat);
  rune5.name = 'rune5';
  rune5.position.set(-1.1548, 0.9854, 0);
  rune5.rotation.set(0, 0, deg(202.5));
  rune5.scale.set(1, 1, 1.9);
  root.add(rune5);
  const rune8 = new THREE.Mesh(octahedronGeo, gemMat);
  rune8.name = 'rune8';
  rune8.position.set(1.1548, 0.9854, 0);
  rune8.rotation.set(0, 0, deg(337.5));
  rune8.scale.set(1, 1, 1.9);
  root.add(rune8);
  const vortex = new THREE.Mesh(new THREE.CylinderGeometry(1.18, 1.18, 0.03, 32), discMat);
  vortex.name = 'vortex';
  vortex.position.set(0, 1.4637, 0);
  vortex.rotation.set(deg(90), 0, 0);
  root.add(vortex);
  const vortexSwirl = new THREE.Group();
  vortexSwirl.name = 'vortexSwirl';
  vortexSwirl.position.set(0, 1.4637, 0);
  root.add(vortexSwirl);
  const swirl1 = new THREE.Mesh(extrude([[0.241, 0], [0.281, 0.0675], [0.3002, 0.153], [0.2927, 0.25], [0.2545, 0.3503], [0.184, 0.4443], [0.0827, 0.5224], [-0.0453, 0.5751], [-0.1931, 0.5943], [-0.3516, 0.5737], [-0.5097, 0.5097], [-0.6556, 0.4017], [-0.7769, 0.2524], [-0.8622, 0.0679], [-0.9016, -0.1428], [-0.8877, -0.3677], [-0.8161, -0.5929], [-0.6863, -0.8036], [-0.5016, -0.9843], [-0.4842, -0.9502], [-0.6556, -0.7677], [-0.7707, -0.5599], [-0.8275, -0.3428], [-0.8285, -0.1312], [-0.7795, 0.0613], [-0.6895, 0.224], [-0.5697, 0.3491], [-0.4322, 0.4322], [-0.2896, 0.4726], [-0.1537, 0.4731], [-0.0346, 0.4392], [0.06, 0.3789], [0.125, 0.3018], [0.1586, 0.2183], [0.1619, 0.1383], [0.139, 0.0708], [0.0964, 0.0231], [0.0422, 0]], 0.06), swirlMat);
  swirl1.name = 'swirl1';
  vortexSwirl.add(swirl1);
  const swirl2 = new THREE.Mesh(extrude([[-0.1205, 0.2087], [-0.1989, 0.2096], [-0.2826, 0.1835], [-0.3629, 0.1285], [-0.4306, 0.0453], [-0.4768, -0.0628], [-0.4938, -0.1895], [-0.4754, -0.3268], [-0.4181, -0.4644], [-0.3211, -0.5913], [-0.1866, -0.6963], [-0.0201, -0.7686], [0.1698, -0.799], [0.3723, -0.7806], [0.5745, -0.7094], [0.7623, -0.5849], [0.9216, -0.4103], [1.0391, -0.1926], [1.1032, 0.0578], [1.065, 0.0558], [0.9926, -0.184], [0.8703, -0.3875], [0.7106, -0.5453], [0.5279, -0.6519], [0.3366, -0.7058], [0.1507, -0.7092], [-0.0175, -0.6679], [-0.1582, -0.5904], [-0.2645, -0.4871], [-0.3328, -0.3697], [-0.363, -0.2495], [-0.3581, -0.1375], [-0.3239, -0.0426], [-0.2683, 0.0282], [-0.2007, 0.0711], [-0.1308, 0.085], [-0.0682, 0.0719], [-0.0211, 0.0365]], 0.06), swirlMat);
  swirl2.name = 'swirl2';
  vortexSwirl.add(swirl2);
  const swirl3 = new THREE.Mesh(extrude([[-0.1205, -0.2087], [-0.0821, -0.2771], [-0.0176, -0.3365], [0.0702, -0.3785], [0.1761, -0.3955], [0.2928, -0.3816], [0.411, -0.3329], [0.5207, -0.2484], [0.6112, -0.1299], [0.6726, 0.0176], [0.6963, 0.1866], [0.6757, 0.3669], [0.607, 0.5466], [0.4898, 0.7127], [0.3271, 0.8522], [0.1254, 0.9526], [-0.1054, 1.0033], [-0.3528, 0.9962], [-0.6017, 0.9265], [-0.5808, 0.8944], [-0.337, 0.9516], [-0.0996, 0.9474], [0.1169, 0.8881], [0.3006, 0.7831], [0.4429, 0.6444], [0.5388, 0.4851], [0.5872, 0.3188], [0.5904, 0.1582], [0.5541, 0.0145], [0.4865, -0.1034], [0.3976, -0.1896], [0.2981, -0.2414], [0.1989, -0.2592], [0.1097, -0.2465], [0.0388, -0.2093], [-0.0082, -0.1558], [-0.0281, -0.095], [-0.0211, -0.0365]], 0.06), swirlMat);
  swirl3.name = 'swirl3';
  vortexSwirl.add(swirl3);
  const core = new THREE.Mesh(new THREE.CylinderGeometry(0.1888, 0.1888, 0.07, 12), swirlMat);
  core.name = 'core';
  core.rotation.set(deg(90), 0, 0);
  vortexSwirl.add(core);

  // Animations ('idle') — play them with THREE.AnimationMixer
  root.animations = codelsClips(THREE, [
    { target: vortexSwirl, anim: [{ type: 'spin', axis: 'z', speed: 40 }, { type: 'pulse', amount: 0.05, period: 3 }] },
  ]);

  root.traverse((o) => {
    if (o.isMesh) {
      o.castShadow = true;
      o.receiveShadow = true;
    }
  });
  return root;
}

export default createPortal;
```

---
Codels — 3D models as code for three.js. Search more: https://codels.download/api/v1/models?q=<query> · Guide: https://codels.download/llms.txt
