Empezamos por la parte más anterior del ojo. ¿Qué forma tiene la córnea, por qué no es una simple esfera y cómo podemos construirla en WebGL?
Pídele a un programador gráfico que modele un ojo y su primer instinto será, casi siempre, usar una esfera de cristal. Se renderiza rápido y tiene un aspecto limpio. El problema es que, si un ojo real tuviera la forma de una esfera perfecta, cada farola de la calle en la noche se convertiría en un borrón resplandeciente.
La cúpula transparente de la parte frontal del ojo, la córnea, tiene una forma mucho más interesante que una esfera, y es la responsable de la mayor parte del enfoque. De las aproximadamente 60 dioptrías de potencia óptica que tiene el ojo, la córnea aporta unas 43. El famoso cristalino en el interior se encarga del resto y realiza el ajuste fino. Pero, ¿cómo sabemos que no es simplemente una cúpula sólida de cristal biológico? Cuando Antonie van Leeuwenhoek observó por primera vez tejido corneal a través de sus microscopios caseros en la década de 1680, descubrió que no era cristal en absoluto, sino un tejido densamente entrelazado de fibras vivas.
En esta serie vamos a construir el ojo pieza a pieza en WebGL. Cada sección a continuación cuenta con una vista interactiva en 3D y una pestaña de Código (Code) con el código fuente completo. Empecemos por la parte frontal.
El casquete esférico: una primera aproximación
Antes de que existieran los escáneres hospitalarios, la gente medía el ojo con trucos ingeniosos. En 1619, Christoph Scheiner colocó canicas de cristal de un tamaño conocido junto al ojo de una persona y las fue comparando hasta que los reflejos coincidían. Dos siglos de perfeccionamiento después, tenemos el número que todo optometrista sigue utilizando: la parte frontal de la córnea tiene un radio de curvatura de aproximadamente R≈7.8mm.
La córnea visible es una ventana ligeramente ovalada, de unos 11.7mm de ancho y 10.6mm de alto, debido a que el blanco del ojo la solapa por arriba y por abajo. Para un primer modelo en WebGL, ignoraremos esto y cortaremos un casquete limpio de una esfera.
En Three.js, una esfera ya sabe cómo dibujarse a sí misma. Solo tenemos que decirle que se quede con la parte frontal limitando el ángulo de barrido vertical:
// Una córnea es, a grandes rasgos, un casquete cortado de la parte frontal de una esfera.// Los dos últimos argumentos conservan solo la porción superior en lugar de toda la bola.const geometry = new THREE.SphereGeometry( 1, // radio 64, 64, // suavidad horizontal y vertical 0, Math.PI * 2, // barrido del círculo completo 0, Math.PI / 4 // pero solo 45 grados hacia abajo desde la parte superior);
import React, { useEffect, useRef } from "react";import * as THREE from "three";import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";import { observeThreeResize } from "../threeResize";export default function CorneaStep1() { const mountRef = useRef<HTMLDivElement>(null); useEffect(() => { if (!mountRef.current) return; // Scene setup const scene = new THREE.Scene(); // Camera setup const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); camera.position.set(-0.176, -0.038, 2.126); camera.zoom = 1; // Renderer setup const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setClearColor(0x000000, 0); // Transparent background mountRef.current.appendChild(renderer.domElement); // Controls setup const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.target.set(-0.734, 0.003, -0.081); const resizeObserver = observeThreeResize( mountRef.current, renderer, camera, ); // Lighting const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 1.5); dirLight.position.set(5, 5, 5); scene.add(dirLight); // Geometry: Simple Sphere (Javal's early approximation) // Radius of curvature ~7.8mm. Let's scale it down for our scene. const radius = 1; const widthSegments = 64; const heightSegments = 64; // We only want a cap. Phi starts at 0 (top), goes down. // A cornea is roughly the anterior 1/6th of the eye. const phiStart = 0; const phiLength = Math.PI / 4; const geometry = new THREE.SphereGeometry( radius, widthSegments, heightSegments, 0, Math.PI * 2, phiStart, phiLength, ); // Material matching WebGLEye "vibe" const material = new THREE.MeshStandardMaterial({ color: 0x52525b, roughness: 0.9, transparent: true, opacity: 0.6, depthWrite: false, side: THREE.DoubleSide, }); const mesh = new THREE.Mesh(geometry, material); // Add wireframe layer for the high-quality tech vibe const wireMaterial = new THREE.MeshBasicMaterial({ color: 0xa1a1aa, wireframe: true, transparent: true, opacity: 0.4, }); const wireMesh = new THREE.Mesh(geometry, wireMaterial); mesh.add(wireMesh); // Rotate so the apex (+Y of sphere) points left (-X) mesh.rotation.z = Math.PI / 2; scene.add(mesh); // Animation Loop let animationFrameId: number; const animate = () => { animationFrameId = requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); const debugEl = mountRef.current ?.closest(".interactive-viewer") ?.querySelector(".debug-output"); if (debugEl) { const state = { camera: { position: { x: Number(camera.position.x.toFixed(3)), y: Number(camera.position.y.toFixed(3)), z: Number(camera.position.z.toFixed(3)), }, rotation: { x: Number(camera.rotation.x.toFixed(3)), y: Number(camera.rotation.y.toFixed(3)), z: Number(camera.rotation.z.toFixed(3)), }, zoom: Number(camera.zoom.toFixed(3)), }, target: { x: Number(controls.target.x.toFixed(3)), y: Number(controls.target.y.toFixed(3)), z: Number(controls.target.z.toFixed(3)), }, }; (debugEl as HTMLElement).innerText = JSON.stringify(state, null, 2); } }; animate(); return () => { cancelAnimationFrame(animationFrameId); resizeObserver.disconnect(); if (mountRef.current && renderer.domElement.parentNode) { mountRef.current.removeChild(renderer.domElement); } renderer.dispose(); geometry.dispose(); material.dispose(); }; }, []); return ( <div className="relative w-full h-full"> <div ref={mountRef} className="w-full h-full min-h-[400px]" style={{ cursor: "grab" }} /> </div> );}
Interact with the preview to capture camera parameters...
Entonces, ¿por qué una esfera es una mala lente? Los rayos de luz que inciden en el borde escarpado de una esfera se curvan mucho más bruscamente que los rayos cerca del centro, por lo que enfocan en puntos ligeramente diferentes. El resultado es la aberración esférica: un punto brillante de luz se difumina en un suave halo en lugar de formar un punto nítido. Para ver bien de noche, el ojo tuvo que abandonar la esfera.
La córnea asférica: aplanando los bordes (el valor Q)
La naturaleza corrige la aberración esférica aplanando suavemente la córnea hacia sus bordes mientras mantiene el centro más curvo. Los ingenieros ópticos describen esta forma matemáticamente usando secciones cónicas (como elipses o parábolas), regidas por un solo número: la constante cónicaQ. La altura de la superficie (llamada sagita, z) a una distancia r desde el centro se define como:
z(r)=1+1−(1+Q)c2r2cr2
Aquí c=1/R es la curvatura en el centro exacto (ápex). El valor de Q cambia la forma:
Q=0 (esfera): curvatura constante en todas partes. Fuertes halos alrededor de las luces nocturnas.
Q<0 (prolata): centro más curvo, bordes más planos. La córnea humana sana se sitúa cerca de Q≈−0.26.
Q>0 (oblata): centro más plano, bordes más curvos. Común después de cirugía láser; empeora el deslumbramiento nocturno.
Curiosamente, la córnea humana no se aplana totalmente hasta el valor que cancelaría por completo la aberración esférica (aproximadamente −0.53). Deja un poco a propósito, porque el cristalino, más profundo en el ojo, curva la luz en sentido opuesto y cancela la mayor parte de lo que queda. Ambos se compensan mutuamente en un ojo joven y sano.
Traducir la fórmula de la sagita a código es casi una copia directa:
const c = 1 / R; // curvatura en el ápexfor (let r = 0; r <= maxR; r += step) { const root = 1 - (1 + Q) * c * c * r * r; const z = (c * r * r) / (1 + Math.sqrt(root)); // altura de la superficie en el radio r // ...colocar un anillo de vértices a esta altura}
Usa los botones a continuación para cambiar entre una córnea humana sana, una esfera perfecta y una forma oblata postcirugía. El color va desde el verde en el centro hasta el color de la forma en el borde, para que puedas ver dónde curva la luz cada una de manera diferente.
Asphericity (Q)
Conic constant Q-0.26
Prolate: steep center, flatter edges. Cancels most spherical aberration.
perfect sphere reference
import React, { useEffect, useRef, useState } from "react";import * as THREE from "three";import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";import { observeThreeResize } from "../threeResize";const R = 7.8;const MAX_R = 5.2; // wide aperture so the edge flattening is easy to see// Three shapes worth comparing, each a single conic constant Q.const PRESETS = [ { id: "human", label: "Normal human", q: -0.26, hex: 0x22d3ee, note: "Prolate: steep center, flatter edges. Cancels most spherical aberration.", }, { id: "sphere", label: "Perfect sphere", q: 0.0, hex: 0xa1a1aa, note: "Constant curvature. Edge rays overbend, so night lights bloom into halos.", }, { id: "lasik", label: "Post-LASIK", q: 0.6, hex: 0xf43f5e, note: "Oblate: flatter center, steeper edges. Amplifies glare after surgery.", },];function sag(r: number, q: number) { const c = 1 / R; const root = 1 - (1 + q) * c * c * r * r; return root >= 0 ? (c * r * r) / (1 + Math.sqrt(root)) : NaN;}function buildGeometry(q: number, hex: number) { const radialSegments = 72; const angularSegments = 72; const vertices: number[] = []; const colors: number[] = []; const indices: number[] = []; const base = new THREE.Color(hex); const apex = new THREE.Color(0x34d399); for (let i = 0; i <= radialSegments; i++) { const r = (i / radialSegments) * MAX_R; let z = sag(r, q); if (Number.isNaN(z)) z = vertices[vertices.length - 1] ?? 0; const col = apex.clone().lerp(base, r / MAX_R); for (let j = 0; j <= angularSegments; j++) { const theta = (j / angularSegments) * Math.PI * 2; vertices.push(r * Math.cos(theta), r * Math.sin(theta), z); colors.push(col.r, col.g, col.b); } } for (let i = 0; i < radialSegments; i++) { for (let j = 0; j < angularSegments; j++) { const a = i * (angularSegments + 1) + j; const b = a + 1; const c2 = (i + 1) * (angularSegments + 1) + j; const d = c2 + 1; indices.push(a, b, d); indices.push(a, d, c2); } } const geometry = new THREE.BufferGeometry(); geometry.setAttribute( "position", new THREE.Float32BufferAttribute(vertices, 3), ); geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3)); geometry.setIndex(indices); geometry.computeVertexNormals(); return geometry;}// The Q = 0 sphere profile in a plane, used as a fixed reference the shape// visibly pulls away from at the edges.function sphereProfile(planeYZ: boolean) { const pts: THREE.Vector3[] = []; for (let t = -MAX_R; t <= MAX_R; t += 0.1) { const z = sag(Math.abs(t), 0); pts.push(planeYZ ? new THREE.Vector3(0, t, z) : new THREE.Vector3(t, 0, z)); } return new THREE.BufferGeometry().setFromPoints(pts);}export default function CorneaAspheric() { const mountRef = useRef<HTMLDivElement>(null); const [preset, setPreset] = useState(PRESETS[0]); const updateRef = useRef<((q: number, hex: number) => void) | null>(null); useEffect(() => { if (!mountRef.current) return; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); camera.position.set(6, 4, 18); // three-quarter view that shows the edge profile scene.add(new THREE.AmbientLight(0xffffff, 0.7)); const dirLight = new THREE.DirectionalLight(0xffffff, 0.9); dirLight.position.set(5, 10, 10); scene.add(dirLight); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setClearColor(0x000000, 0); mountRef.current.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.target.set(0, 0, 0); const resizeObserver = observeThreeResize( mountRef.current, renderer, camera, ); const material = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.25, metalness: 0.1, transparent: true, opacity: 0.9, side: THREE.DoubleSide, }); const wireMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, transparent: true, opacity: 0.12, }); let geo = buildGeometry(preset.q, preset.hex); const mesh = new THREE.Mesh(geo, material); const wire = new THREE.Mesh(geo, wireMaterial); mesh.add(wire); scene.add(mesh); // Fixed dashed reference: where a perfect sphere (Q = 0) would sit. const refMat = new THREE.LineDashedMaterial({ color: 0xffffff, transparent: true, opacity: 0.5, dashSize: 0.25, gapSize: 0.15, }); [sphereProfile(false), sphereProfile(true)].forEach((g) => { const line = new THREE.Line(g, refMat); line.computeLineDistances(); scene.add(line); }); updateRef.current = (q: number, hex: number) => { const newGeo = buildGeometry(q, hex); mesh.geometry.dispose(); mesh.geometry = newGeo; wire.geometry.dispose(); wire.geometry = new THREE.WireframeGeometry(newGeo); geo = newGeo; }; let animationFrameId: number; const animate = () => { animationFrameId = requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); const debugEl = mountRef.current ?.closest(".interactive-viewer") ?.querySelector(".debug-output"); if (debugEl) { const state = { camera: { position: { x: +camera.position.x.toFixed(3), y: +camera.position.y.toFixed(3), z: +camera.position.z.toFixed(3), }, }, target: { x: +controls.target.x.toFixed(3), y: +controls.target.y.toFixed(3), z: +controls.target.z.toFixed(3), }, }; (debugEl as HTMLElement).innerText = JSON.stringify(state, null, 2); } }; animate(); return () => { cancelAnimationFrame(animationFrameId); resizeObserver.disconnect(); if (mountRef.current && renderer.domElement.parentNode) { mountRef.current.removeChild(renderer.domElement); } renderer.dispose(); geo.dispose(); material.dispose(); wireMaterial.dispose(); refMat.dispose(); updateRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { updateRef.current?.(preset.q, preset.hex); }, [preset]); return ( <div className="relative w-full h-[450px] md:h-[550px] bg-transparent overflow-hidden rounded-lg group"> <div className="absolute top-4 left-4 z-10 flex flex-col items-start gap-1 pointer-events-none bg-card/80 backdrop-blur-md border border-border p-3 rounded-xl shadow-lg max-w-[240px]"> <span className="text-[10px] uppercase tracking-wider font-bold text-muted-foreground border-b border-border/50 pb-1 w-full text-center mb-1"> Asphericity (Q) </span> <div className="flex justify-between w-full text-xs"> <span className="font-bold">Conic constant Q</span> <span className="font-mono font-bold" style={{ color: `#${preset.hex.toString(16)}` }} > {preset.q.toFixed(2)} </span> </div> <div className="text-[10px] text-muted-foreground mt-1"> {preset.note} </div> <div className="flex items-center gap-2 text-[10px] text-muted-foreground mt-1 pt-1 border-t border-border/40 w-full"> <span className="w-4 border-t border-dashed border-white/70 inline-block"></span>{" "} perfect sphere reference </div> </div> <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 flex gap-2 bg-card/80 backdrop-blur-md border border-border p-2 rounded-2xl shadow-xl"> {PRESETS.map((p) => ( <button key={p.id} onClick={() => setPreset(p)} className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-colors ${preset.id === p.id ? "text-white" : "text-muted-foreground hover:text-foreground"}`} style={ preset.id === p.id ? { backgroundColor: `#${p.hex.toString(16)}` } : undefined } > {p.label} </button> ))} </div> <div ref={mountRef} className="w-full h-full cursor-grab active:cursor-grabbing" /> </div> );}
Interact with the preview to capture camera parameters...
Astigmatismo: una córnea con forma de balón de rugby
Una córnea perfecta está igualmente curvada en todas las direcciones, como un corte de una pelota de baloncesto. Una córnea astigmática está más curvada en una dirección que en otra, como la parte posterior de una cuchara o un balón de rugby. Esto es el astigmatismo regular, y Thomas Young lo midió por primera vez en sus propios ojos en 1801.
Lo modelamos dándole a los dos ejes sus propias curvaturas en lugar de compartir una sola. Esto se llama una superficie tórica. En código, es la misma fórmula de la sagita de antes, pero con una curvatura separada para x y para y:
// Ya no basta con una sola curvatura: cx a lo largo de X, cy a lo largo de Y.const root = 1 - (1 + Q) * (cx*cx*x*x + cy*cy*y*y);const z = (cx*x*x + cy*y*y) / (1 + Math.sqrt(root));
Las dos direcciones de máxima y mínima curvatura se denominan meridianos. En la vista a continuación, el meridiano más plano está dibujado en azul y el más curvo en rojo. Debido a que los dos meridianos enfocan a distancias diferentes, un ojo astigmático nunca forma un único punto nítido. La luz colapsa en dos líneas focales cortas con una zona de “menor desenfoque” entre ellas, una forma que en óptica se llama conoide de Sturm.
Toric Cornea
X axis: flat meridian (Rx = 7.8 mm)
Y axis: steep meridian (Ry = 6.0 mm)
Z axis: surface height
CurvatureSteepFlat
import React, { useEffect, useRef } from "react";import * as THREE from "three";import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";import { observeThreeResize } from "../threeResize";export default function CorneaAstigmatism() { const mountRef = useRef<HTMLDivElement>(null); useEffect(() => { if (!mountRef.current) return; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); camera.position.set(14.87, 6.151, -2.13); scene.add(new THREE.AmbientLight(0xffffff, 0.6)); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(5, 10, 7); scene.add(dirLight); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setClearColor(0x000000, 0); mountRef.current.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.target.set(1.008, 2.253, -0.219); const resizeObserver = observeThreeResize( mountRef.current, renderer, camera, ); // Toric ("football") surface: flat radius on X, steep radius on Y. const Rx = 7.8; const Ry = 6.0; const cx = 1 / Rx; const cy = 1 / Ry; const k = -0.26; const maxR = 5.0; const radialSegments = 64; const angularSegments = 96; const vertices: number[] = []; const colors: number[] = []; const indices: number[] = []; const color = new THREE.Color(); const minC = Math.min(cx, cy); const maxC = Math.max(cx, cy); for (let i = 0; i <= radialSegments; i++) { const r = (i / radialSegments) * maxR; for (let j = 0; j <= angularSegments; j++) { const theta = (j / angularSegments) * Math.PI * 2; const x = r * Math.cos(theta); const y = r * Math.sin(theta); let z = 0; if (r > 0) { const root = 1 - (1 + k) * (cx * cx * x * x + cy * cy * y * y); z = root >= 0 ? (cx * x * x + cy * y * y) / (1 + Math.sqrt(root)) : vertices[vertices.length - 3] || 0; } vertices.push(x, y, z); // True meridional curvature at this azimuth (Euler): blue = flat, red = steep. const localC = cx * Math.cos(theta) ** 2 + cy * Math.sin(theta) ** 2; const t = (localC - minC) / (maxC - minC); color.setHSL(0.66 * (1 - t), 0.95, 0.5); colors.push(color.r, color.g, color.b); } } for (let i = 0; i < radialSegments; i++) { for (let j = 0; j < angularSegments; j++) { const a = i * (angularSegments + 1) + j; const b = a + 1; const c = (i + 1) * (angularSegments + 1) + j; const d = c + 1; indices.push(a, b, d); indices.push(a, d, c); } } const geometry = new THREE.BufferGeometry(); geometry.setAttribute( "position", new THREE.Float32BufferAttribute(vertices, 3), ); geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3)); geometry.setIndex(indices); geometry.computeVertexNormals(); const material = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.2, metalness: 0.1, transparent: true, opacity: 0.55, depthWrite: false, side: THREE.DoubleSide, }); const mesh = new THREE.Mesh(geometry, material); mesh.rotation.x = -Math.PI / 2; scene.add(mesh); mesh.add( new THREE.Mesh( geometry, new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, transparent: true, opacity: 0.1, }), ), ); // XYZ axes (R = x, G = y, B = z), the notation from the original view. mesh.add(new THREE.AxesHelper(6.5)); let animationFrameId: number; const animate = () => { animationFrameId = requestAnimationFrame(animate); mesh.rotation.z += 0.004; // gentle spin so the oval reads clearly controls.update(); renderer.render(scene, camera); const debugEl = mountRef.current ?.closest(".interactive-viewer") ?.querySelector(".debug-output"); if (debugEl) { const state = { camera: { position: { x: +camera.position.x.toFixed(3), y: +camera.position.y.toFixed(3), z: +camera.position.z.toFixed(3), }, }, target: { x: +controls.target.x.toFixed(3), y: +controls.target.y.toFixed(3), z: +controls.target.z.toFixed(3), }, }; (debugEl as HTMLElement).innerText = JSON.stringify(state, null, 2); } }; animate(); return () => { cancelAnimationFrame(animationFrameId); resizeObserver.disconnect(); if (mountRef.current && renderer.domElement.parentNode) { mountRef.current.removeChild(renderer.domElement); } renderer.dispose(); geometry.dispose(); material.dispose(); }; }, []); return ( <div className="relative w-full h-[450px] md:h-[550px] flex flex-col items-center overflow-hidden rounded-lg group bg-transparent"> <div className="absolute top-4 left-4 z-10 flex flex-col items-start gap-1 pointer-events-none bg-card/80 backdrop-blur-md border border-border p-3 rounded-xl shadow-lg text-xs"> <span className="text-[10px] uppercase tracking-wider font-bold text-muted-foreground border-b border-border/50 pb-1 w-full text-center mb-1"> Toric Cornea </span> <div className="flex items-center gap-2"> <span className="w-3 h-0.5 bg-red-500 inline-block"></span> X axis: flat meridian (Rx = 7.8 mm) </div> <div className="flex items-center gap-2"> <span className="w-3 h-0.5 bg-green-500 inline-block"></span> Y axis: steep meridian (Ry = 6.0 mm) </div> <div className="flex items-center gap-2"> <span className="w-3 h-0.5 bg-blue-500 inline-block"></span> Z axis: surface height </div> </div> <div className="absolute top-4 right-4 z-10 flex flex-col items-center gap-2 pointer-events-none bg-card/60 backdrop-blur-md border border-border p-3 rounded-xl shadow-lg"> <span className="text-[10px] uppercase tracking-wider font-bold text-muted-foreground border-b border-border/50 pb-1 w-full text-center"> Curvature </span> <span className="text-[10px] font-bold text-red-500/70 mt-1"> Steep </span> <div className="w-4 h-24 rounded-full bg-gradient-to-b from-red-500 via-green-500 to-blue-500 shadow-inner ring-1 ring-white/10"></div> <span className="text-[10px] font-bold text-blue-500/70 mb-1"> Flat </span> </div> <div ref={mountRef} className="w-full h-full cursor-grab active:cursor-grabbing" /> </div> );}
Interact with the preview to capture camera parameters...
Dentro de la córnea: seis capas
De cerca, la córnea no es una única lámina transparente, sino seis capas superpuestas, todas concentradas en aproximadamente medio milímetro (el espesor central medio es de unos 540μm):
Capa
Espesor
Índice de refracción
Función
1. Epitelio
≈50μm
1.401
Células externas de renovación; da a las lágrimas una superficie lisa.
2. Capa de Bowman
≈10μm
1.380
Resistente lámina de colágeno; protege contra arañazos.
3. Estroma
≈452μm
1.376
90% del grosor; cientos de láminas de colágeno ordenadas.
4. Capa de Dua
≈15μm
1.376
Capa pre-Descemet delgada pero muy resistente.
5. Membrana de Descemet
≈8μm
1.358
Base elástica sobre la que se asienta el endotelio.
6. Endotelio
≈5μm
1.335
Monocapa de células que bombean agua hacia afuera.
¿Por qué es transparente? El estroma está hecho de colágeno, la misma proteína de la que están hechos los tendones (que son blancos y opacos). El truco está en la separación: las fibras de colágeno están empaquetadas a una distancia menor que la mitad de la longitud de onda de la luz, de modo que la luz que dispersan se cancela lateralmente y solo sobrevive la luz que viaja hacia adelante (Benedek, 1971). Si las bombas endoteliales fallan y entra agua, las fibras se separan, la cancelación se rompe y la córnea se vuelve blanca y turbia.
Un debate anatómico reciente. Durante más de un siglo, los libros de texto enumeraban firmemente cinco capas. Luego, en 2013, investigadores propusieron una controvertida sexta capa (la capa de Dua), una lámina fina e inusualmente dura justo delante de la membrana de Descemet. Aunque muchos anatomistas aún debaten si se trata realmente de una capa nueva o simplemente del borde posterior denso del estroma, su descubrimiento ayudó a explicar por qué una técnica quirúrgica que inyecta una burbuja de aire para separar el tejido corneal lo divide exactamente por donde lo hace.
El modelo a continuación es una sección transversal anatómica dibujada a escala, para que puedas ver cuánta córnea es realmente estroma. Las láminas de colágeno del estroma se muestran como fibras cuya dirección gira de una lámina a otra (como el contrachapado, de donde obtiene su resistencia), y el endotelio se dibuja como su verdadero mosaico hexagonal. Presiona el botón para separar las capas.
Corneal Layers (to scale)
1. Epithelium
50 µm
2. Bowman's Layer
10 µm
3. Stroma
452 µm
4. Dua's Layer
15 µm
5. Descemet's Membrane
8 µm
6. Endothelium
5 µm
import React, { useEffect, useRef, useState } from "react";import * as THREE from "three";import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";import { observeThreeResize } from "../threeResize";// Real thickness (µm) is the single source of truth. Slab heights below are// drawn to true relative scale, so the stroma really does dwarf the rest.const LAYERS = [ { id: "epi", name: "1. Epithelium", um: 50, color: 0x38bdf8, detail: "cells", desc: "Renewing cell layer (5-7 day turnover) with a smooth tear surface.", }, { id: "bowman", name: "2. Bowman's Layer", um: 10, color: 0x34d399, detail: "solid", desc: "Tough, acellular collagen anchor. Does not regenerate once cut.", }, { id: "stroma", name: "3. Stroma", um: 452, color: 0x94a3b8, detail: "lamellae", desc: "90% of thickness. Hundreds of collagen sheets, each rotated against the next.", }, { id: "dua", name: "4. Dua's Layer", um: 15, color: 0xfbbf24, detail: "solid", desc: "Thin, very strong pre-Descemet layer (Dua, 2013).", }, { id: "descemet", name: "5. Descemet's Membrane", um: 8, color: 0x818cf8, detail: "solid", desc: "Elastic basement membrane the endothelium sits on.", }, { id: "endo", name: "6. Endothelium", um: 5, color: 0xf43f5e, detail: "hex", desc: "Single sheet of hexagonal cells pumping water out to keep the cornea clear.", },];const SCALE = 0.011; // µm -> world unitsconst HALF = 2.6; // footprint half-widthconst TOTAL = LAYERS.reduce((s, l) => s + l.um, 0);// Parallel collagen fibers on a plane at height y, rotated by `angle`.function fiberLines(y: number, angle: number, color: number) { const pts: number[] = []; const n = 16; for (let i = 0; i < n; i++) { const off = -HALF + (i / (n - 1)) * 2 * HALF; pts.push(-HALF, y, off, HALF, y, off); } const geo = new THREE.BufferGeometry(); geo.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3)); const line = new THREE.LineSegments( geo, new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.55 }), ); line.rotation.y = angle; return line;}// Hexagon outlines tiling a plane (endothelial mosaic).function hexGrid(y: number, color: number) { const group = new THREE.Group(); const R = 0.34; const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.8, }); const dx = R * 1.5; const dz = R * Math.sqrt(3); for (let row = -5; row <= 5; row++) { for (let col = -5; col <= 5; col++) { const cx = col * dx; const cz = row * dz + (col % 2 ? dz / 2 : 0); if (Math.abs(cx) > HALF - 0.1 || Math.abs(cz) > HALF - 0.1) continue; const pts: THREE.Vector3[] = []; for (let k = 0; k <= 6; k++) { const a = (k / 6) * Math.PI * 2; pts.push( new THREE.Vector3(cx + R * Math.cos(a), y, cz + R * Math.sin(a)), ); } group.add( new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), mat), ); } } return group;}// Small rounded cells scattered on a plane (epithelial mosaic).function cellDots(y: number, color: number) { const group = new THREE.Group(); const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.5, side: THREE.DoubleSide, }); const geo = new THREE.CircleGeometry(0.2, 14); for (let x = -HALF + 0.25; x < HALF; x += 0.5) { for (let z = -HALF + 0.25; z < HALF; z += 0.5) { const m = new THREE.Mesh(geo, mat); m.position.set( x + (Math.random() - 0.5) * 0.12, y, z + (Math.random() - 0.5) * 0.12, ); m.rotation.x = -Math.PI / 2; group.add(m); } } return group;}export default function CorneaLayered() { const mountRef = useRef<HTMLDivElement>(null); const [exploded, setExploded] = useState(false); const [active, setActive] = useState<string | null>(null); const explodedRef = useRef(false); const activeRef = useRef<string | null>(null); explodedRef.current = exploded; activeRef.current = active; useEffect(() => { if (!mountRef.current) return; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); camera.position.set(6, 3.5, 8); scene.add(new THREE.AmbientLight(0xffffff, 0.85)); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(6, 12, 8); scene.add(dirLight); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setClearColor(0x000000, 0); mountRef.current.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; const resizeObserver = observeThreeResize( mountRef.current, renderer, camera, ); const layerGroups: { group: THREE.Group; slab: THREE.Mesh; index: number; baseY: number; trueH: number; solid: boolean; }[] = []; let top = (TOTAL * SCALE) / 2; LAYERS.forEach((l, idx) => { const h = Math.max(l.um * SCALE, 0.06); // floor so the thinnest layers stay visible const centerY = top - (l.um * SCALE) / 2; top -= l.um * SCALE; const group = new THREE.Group(); const isStroma = l.detail === "lamellae"; const slabGeo = new THREE.BoxGeometry(2 * HALF, h, 2 * HALF); const slabMat = new THREE.MeshStandardMaterial({ color: l.color, roughness: 0.55, // Only the big stroma is see-through, so its fibers show without the // whole stack turning into overlapping glass (the earlier glitch). transparent: isStroma, opacity: isStroma ? 0.35 : 1.0, depthWrite: !isStroma, }); const slab = new THREE.Mesh(slabGeo, slabMat); group.add(slab); // Crisp outline so each layer reads as a distinct block. const edges = new THREE.LineSegments( new THREE.EdgesGeometry(slabGeo), new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.25, }), ); group.add(edges); if (isStroma) { const sheets = 14; for (let s = 0; s < sheets; s++) { const ly = -h / 2 + ((s + 0.5) / sheets) * h; const angle = s % 2 === 0 ? 0 : Math.PI / 2; // plywood: orthogonal sheets group.add(fiberLines(ly, angle, 0xe2e8f0)); } } else if (l.detail === "hex") { group.add(hexGrid(h / 2 + 0.01, 0xffffff)); } else if (l.detail === "cells") { group.add(cellDots(h / 2 + 0.01, 0xffffff)); } group.position.y = centerY; scene.add(group); layerGroups.push({ group, slab, index: idx, baseY: centerY, trueH: h, solid: !isStroma, }); }); let explodeT = 0; let animationFrameId: number; const animate = () => { animationFrameId = requestAnimationFrame(animate); const target = explodedRef.current ? 1 : 0; explodeT += (target - explodeT) * 0.08; const gap = 0.95; const DISPLAY_H = 0.5; layerGroups.forEach(({ group, slab, index, baseY, trueH, solid }) => { // Collapsed: layers sit at true relative scale (stroma dominates). // Exploded: each layer compresses to a uniform thin plate and fans out // to an evenly spaced, ordered position, so nothing overlaps. The µm // labels in the panel carry the real thickness. const explodedY = (2.5 - index) * gap; group.position.y = baseY * (1 - explodeT) + explodedY * explodeT; group.scale.y = 1 - explodeT + (DISPLAY_H / trueH) * explodeT; const isActive = activeRef.current === LAYERS[index].id; const mat = slab.material as THREE.MeshStandardMaterial; const dim = activeRef.current && !isActive; mat.opacity = solid ? (dim ? 0.25 : 1.0) : dim ? 0.12 : 0.35; mat.emissive.setHex(isActive ? 0x444444 : 0x000000); }); controls.update(); renderer.render(scene, camera); const debugEl = mountRef.current ?.closest(".interactive-viewer") ?.querySelector(".debug-output"); if (debugEl) { const state = { camera: { position: { x: +camera.position.x.toFixed(3), y: +camera.position.y.toFixed(3), z: +camera.position.z.toFixed(3), }, }, target: { x: +controls.target.x.toFixed(3), y: +controls.target.y.toFixed(3), z: +controls.target.z.toFixed(3), }, }; (debugEl as HTMLElement).innerText = JSON.stringify(state, null, 2); } }; animate(); return () => { cancelAnimationFrame(animationFrameId); resizeObserver.disconnect(); if (mountRef.current && renderer.domElement.parentNode) { mountRef.current.removeChild(renderer.domElement); } renderer.dispose(); }; }, []); return ( <div className="relative w-full h-[500px] md:h-[600px] bg-transparent overflow-hidden rounded-lg group"> <div className="absolute top-4 left-4 z-10 flex flex-col items-start gap-1 bg-card/80 backdrop-blur-md border border-border p-3 rounded-xl shadow-lg max-w-[260px]"> <span className="text-[10px] uppercase tracking-wider font-bold text-muted-foreground mb-1 border-b border-border/50 pb-1 w-full text-center"> Corneal Layers (to scale) </span> <div className="flex flex-col gap-1 w-full text-xs"> {LAYERS.map((l) => ( <div key={l.id} onClick={() => setActive(active === l.id ? null : l.id)} className={`flex justify-between items-center px-1.5 py-0.5 rounded cursor-pointer transition-colors ${active === l.id ? "bg-primary/20 border border-primary/50" : "hover:bg-muted/40"}`} > <div className="flex items-center gap-1.5"> <span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: `#${l.color.toString(16).padStart(6, "0")}`, }} ></span> <span className="font-semibold text-foreground text-[11px]"> {l.name} </span> </div> <span className="font-mono text-[10px] text-muted-foreground font-bold ml-2"> {l.um} µm </span> </div> ))} </div> {active && ( <p className="text-[10px] text-muted-foreground leading-relaxed mt-1 pt-1 border-t border-border/40"> {LAYERS.find((l) => l.id === active)?.desc} </p> )} </div> <button onClick={() => setExploded((e) => !e)} className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 px-5 py-2 rounded-full bg-primary text-primary-foreground font-bold text-sm shadow-xl hover:opacity-90 transition-opacity" > {exploded ? "Collapse layers" : "Explode layers"} </button> <div ref={mountRef} className="w-full h-full cursor-grab active:cursor-grabbing" /> </div> );}
Interact with the preview to capture camera parameters...
La película lagrimal y sus colores de mancha de aceite
Lo primero que encuentra la luz no son células, sino lágrimas. Una película de solo 3 a 5μm de grosor recubre la córnea, y su piel más externa es una capa de aceite (lípida) de apenas 40 a 100nm de espesor, esparcida por las glándulas de los párpados para frenar la evaporación.
Esa capa de aceite es más delgada que una longitud de onda de luz, que es exactamente la condición para la interferencia de película delgada, el mismo efecto que pinta arcoíris en las pompas de jabón y los charcos de agua. La luz que rebota en la parte superior de la película y la que rebota en el fondo recorren distancias ligeramente diferentes, por lo que algunos colores se refuerzan y otros se cancelan. Los colores que brillan más satisfacen:
2ndcosθ=(m−21)λ,m=1,2,3,…
donde n es el índice de refracción del aceite, d su espesor, θ el ángulo de la luz dentro de la película, λ la longitud de onda, y m es el orden entero de interferencia. Arrastra el modelo para atrapar el tenue brillo cambiante a lo largo de la superficie.
Tear Film: Thin Film Interference
A wafer-thin oil layer floating on the watery tears. As its thickness drifts, different colors cancel and reinforce, giving the faint oil-slick sheen.
Interact with the preview to capture camera parameters...
Cuando algo va mal: el queratocono
Si el colágeno del estroma se debilita, la presión normal del ojo puede empujar la córnea hacia afuera formando un cono. Esto se conoce como queratocono, y el abultamiento suele formarse abajo y ligeramente descentrado en lugar de en el ápex.
Biomecánicamente, el queratocono implica un peligroso adelgazamiento del tejido. Pero para aproximarlo de forma puramente visual en nuestro shader, podemos hacer lo contrario: empezamos con una córnea esférica sana y añadimos un pequeño bulto gaussiano debajo del centro.
let z = Math.sqrt(R*R - rho*rho) - R; // córnea esférica sanaconst dist = Math.hypot(x, y + 1.5); // cono centrado a 1.5 mm debajo del ápexz += 1.5 * Math.exp(-(dist * dist) / 2); // añadimos el abultamiento hacia adelante
El efecto en la visión es peor que la simple miopía. El cono no es simétrico, por lo que no solo desplaza el enfoque, sino que lo distorsiona. Una farola se difumina en un rayo parecido a un cometa (los ópticos llaman a esto coma), y esta forma irregular no se puede corregir con gafas normales, solo con lentes de contacto rígidas o cirugía que remoldee o refuerce el tejido. Arrastra el modelo para ver el cono de perfil.
Keratoconus
A thinned cornea bulging forward into a cone, low and off-center. Drag to rotate and see the profile.
↓ Inferior (cone forms here)
import React, { useEffect, useRef } from "react";import * as THREE from "three";import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";import { observeThreeResize } from "../threeResize";export default function CorneaKeratoconus() { const mountRef = useRef<HTMLDivElement>(null); useEffect(() => { if (!mountRef.current) return; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); camera.position.set(2.2, 0.4, 3); // fixed three-quarter side view so the cone is obvious const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setClearColor(0x000000, 0); mountRef.current.appendChild(renderer.domElement); const orbit = new OrbitControls(camera, renderer.domElement); orbit.enableDamping = true; orbit.dampingFactor = 0.05; orbit.enableZoom = false; const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 1.0); dirLight.position.set(5, 5, 5); scene.add(dirLight); // Fixed, advanced keratoconus. A healthy sphere plus a Gaussian bump // pushed below center (inferior), which is where the cone usually forms. const rBase = 7.8; const severity = 1.0; const segments = 96; const vertices: number[] = []; const indices: number[] = []; const colors: number[] = []; const col = new THREE.Color(); for (let i = 0; i <= segments; i++) { const rho = (i / segments) * 5.0; for (let j = 0; j <= segments; j++) { const theta = (j / segments) * Math.PI * 2; const x = rho * Math.cos(theta); const y = rho * Math.sin(theta); let z = Math.sqrt(Math.max(0, rBase * rBase - rho * rho)) - rBase; // Gaussian cone, centered 1.5 mm below the apex. const dy = y + 1.5; const dist = Math.sqrt(x * x + dy * dy); const bulge = severity * 1.5 * Math.exp(-(dist * dist) / 2.0); z += bulge; vertices.push(x * 0.2, y * 0.2, z * 0.2); // Color by how far the surface bulges forward: blue (flat) to red (cone). const t = THREE.MathUtils.clamp(bulge / 1.2, 0, 1); col.setHSL(0.66 * (1 - t), 1.0, 0.5); colors.push(col.r, col.g, col.b); } } for (let i = 0; i < segments; i++) { for (let j = 0; j < segments; j++) { const a = i * (segments + 1) + j; const b = a + segments + 1; const c = a + 1; const d = b + 1; indices.push(a, b, c); indices.push(c, b, d); } } const geometry = new THREE.BufferGeometry(); geometry.setAttribute( "position", new THREE.Float32BufferAttribute(vertices, 3), ); geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3)); geometry.setIndex(indices); geometry.computeVertexNormals(); const material = new THREE.MeshPhysicalMaterial({ vertexColors: true, transmission: 0.6, opacity: 0.9, metalness: 0.1, roughness: 0.15, ior: 1.376, transparent: true, side: THREE.DoubleSide, }); const mesh = new THREE.Mesh(geometry, material); const wireMesh = new THREE.Mesh( geometry, new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, transparent: true, opacity: 0.12, }), ); mesh.add(wireMesh); scene.add(mesh); let animationFrameId: number; const animate = () => { animationFrameId = requestAnimationFrame(animate); orbit.update(); renderer.render(scene, camera); const debugEl = mountRef.current ?.closest(".interactive-viewer") ?.querySelector(".debug-output"); if (debugEl) { const state = { camera: { position: { x: Number(camera.position.x.toFixed(3)), y: Number(camera.position.y.toFixed(3)), z: Number(camera.position.z.toFixed(3)), }, target: { x: Number(orbit.target.x.toFixed(3)), y: Number(orbit.target.y.toFixed(3)), z: Number(orbit.target.z.toFixed(3)), }, }, }; (debugEl as HTMLElement).innerText = JSON.stringify(state, null, 2); } }; animate(); const resizeObserver = observeThreeResize( mountRef.current, renderer, camera, ); return () => { resizeObserver.disconnect(); cancelAnimationFrame(animationFrameId); if (mountRef.current && renderer.domElement.parentNode) { mountRef.current.removeChild(renderer.domElement); } renderer.dispose(); geometry.dispose(); material.dispose(); }; }, []); return ( <div className="relative w-full h-[500px] bg-transparent overflow-hidden rounded-lg group"> <div className="absolute top-4 left-4 z-10 flex flex-col items-start gap-1 pointer-events-none bg-card/80 backdrop-blur-md border border-border p-3 rounded-xl shadow-lg"> <span className="text-[10px] uppercase tracking-wider font-bold text-muted-foreground mb-1 border-b border-border/50 pb-1 w-full text-center"> Keratoconus </span> <div className="text-[10px] text-muted-foreground max-w-[200px]"> A thinned cornea bulging forward into a cone, low and off-center. Drag to rotate and see the profile. </div> </div> <div className="absolute bottom-4 left-4 z-10 text-[10px] font-bold text-amber-400/80 pointer-events-none"> ↓ Inferior (cone forms here) </div> <div ref={mountRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" /> </div> );}
Interact with the preview to capture camera parameters...
Midiendo la forma: discos de Plácido
¿Cómo mide un óptico estas diminutas protuberancias sin tocar el ojo? Observando los reflejos. En 1880, António Plácido proyectó un objetivo de anillos concéntricos blancos y negros sobre la córnea y observó el reflejo. En una córnea lisa, los anillos se reflejan como círculos limpios. Donde la córnea es más pronunciada, los anillos reflejados se agrupan más juntos; donde es más plana, se separan. Una córnea distorsionada produce anillos distorsionados, como mirar a través de un cristal deformado. Los topógrafos computarizados modernos siguen funcionando exactamente de la misma manera.
Topography
Steeper = redder
The rings on the right reflect off this same surface. They crowd together where the cornea steepens (red), just like a real topographer.
Placido Reflex
import React, { useEffect, useRef } from "react";import * as THREE from "three";import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";import { observeThreeResize } from "../threeResize";// One shared shape drives both the 3D topography and the 2D ring reflection,// so the rings crowd together exactly where the 3D surface steepens.// Mild astigmatism (steeper along Y) plus an inferior cone.const CY = 1 / 6.6; // steep meridian curvature (1/mm)const CX = 1 / 7.8; // flat meridian curvature (1/mm)const CONE_X = 1.0;const CONE_Y = -1.5; // inferiorconst CONE_R = 2.2;// Local corneal power (relative units) at a point, blending the astigmatic// base with the cone. Higher = steeper.function localPower(x: number, y: number) { const r2 = x * x + y * y || 1; const base = (CX * (x * x)) / r2 + (CY * (y * y)) / r2; const dx = x - CONE_X; const dy = y - CONE_Y; const dist = Math.sqrt(dx * dx + dy * dy); const cone = dist < CONE_R ? Math.cos(((dist / CONE_R) * Math.PI) / 2) ** 2 * 0.06 : 0; return base + cone;}function surfaceZ(x: number, y: number) { const k = -0.26; const denom = 1 + Math.sqrt(Math.max(0, 1 - (1 + k) * (CX * CX * x * x + CY * CY * y * y))); let z = (CX * x * x + CY * y * y) / denom; const dx = x - CONE_X; const dy = y - CONE_Y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < CONE_R) z -= Math.cos(((dist / CONE_R) * Math.PI) / 2) ** 2 * 0.6; return z;}// A Placido disc reflects like a convex mirror: steeper cornea pulls the// reflected ring inward. We scale each ring radius by (reference / local power).function drawPlacido(canvas: HTMLCanvasElement) { const ctx = canvas.getContext("2d"); if (!ctx) return; const S = 130; const cxp = S / 2; const refPower = (CX + CY) / 2; ctx.clearRect(0, 0, S, S); ctx.strokeStyle = "#ffffff"; for (let ring = 1; ring <= 9; ring++) { const baseR = (ring / 9) * (S / 2 - 6); const rhoMm = (ring / 9) * 4.5; // mm on the cornea this ring reflects from ctx.beginPath(); for (let a = 0; a <= Math.PI * 2 + 0.05; a += 0.05) { const x = rhoMm * Math.cos(a); const y = rhoMm * Math.sin(a); const scale = THREE.MathUtils.clamp( refPower / localPower(x, y), 0.6, 1.4, ); // Canvas Y grows downward, so negate it to keep the reflex oriented the // same way as the 3D map (inferior cone crowds at the bottom). const px = cxp + Math.cos(a) * baseR * scale; const py = cxp - Math.sin(a) * baseR * scale; if (a === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); } ctx.closePath(); ctx.lineWidth = ring > 5 ? 0.9 : 1.3; ctx.stroke(); }}function buildCornea() { const maxR = 5.0; const radial = 80; const angular = 80; const v: number[] = []; const colors: number[] = []; const indices: number[] = []; const col = new THREE.Color(); const refPower = (CX + CY) / 2; for (let i = 0; i <= radial; i++) { const r = (i / radial) * maxR; for (let j = 0; j <= angular; j++) { const theta = (j / angular) * Math.PI * 2; const x = r * Math.cos(theta); const y = r * Math.sin(theta); v.push(x, y, surfaceZ(x, y)); const t = THREE.MathUtils.clamp( (localPower(x, y) - refPower) / 0.05 + 0.3, 0, 1, ); col.setHSL(0.66 * (1 - t), 1.0, 0.5); colors.push(col.r, col.g, col.b); } } for (let i = 0; i < radial; i++) { for (let j = 0; j < angular; j++) { const a = i * (angular + 1) + j; const b = a + 1; const c = (i + 1) * (angular + 1) + j; const d = c + 1; indices.push(a, b, d); indices.push(a, d, c); } } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.Float32BufferAttribute(v, 3)); geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3)); geometry.setIndex(indices); geometry.computeVertexNormals(); return geometry;}export default function CorneaPlacido() { const mountRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null); useEffect(() => { if (!mountRef.current) return; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); camera.position.set(-16, 0, 0.2); // looking straight down the optical axis const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setClearColor(0x000000, 0); mountRef.current.appendChild(renderer.domElement); const orbit = new OrbitControls(camera, renderer.domElement); orbit.enableDamping = true; orbit.dampingFactor = 0.05; orbit.target.set(0, 0, 0); scene.add(new THREE.AmbientLight(0xffffff, 1.0)); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(10, 10, 10); scene.add(dirLight); const geometry = buildCornea(); const corneaMat = new THREE.MeshPhysicalMaterial({ vertexColors: true, transmission: 0.1, metalness: 0.1, roughness: 0.4, ior: 1.376, thickness: 1.0, side: THREE.DoubleSide, }); const corneaMesh = new THREE.Mesh(geometry, corneaMat); corneaMesh.rotation.y = Math.PI / 2; // apex toward -X scene.add(corneaMesh); const wireMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.12, }); corneaMesh.add( new THREE.LineSegments(new THREE.WireframeGeometry(geometry), wireMat), ); if (canvasRef.current) drawPlacido(canvasRef.current); let animationFrameId: number; const animate = () => { animationFrameId = requestAnimationFrame(animate); orbit.update(); renderer.render(scene, camera); const debugEl = mountRef.current ?.closest(".interactive-viewer") ?.querySelector(".debug-output"); if (debugEl) { const state = { camera: { position: { x: Number(camera.position.x.toFixed(3)), y: Number(camera.position.y.toFixed(3)), z: Number(camera.position.z.toFixed(3)), }, }, target: { x: Number(orbit.target.x.toFixed(3)), y: Number(orbit.target.y.toFixed(3)), z: Number(orbit.target.z.toFixed(3)), }, }; (debugEl as HTMLElement).innerText = JSON.stringify(state, null, 2); } }; animate(); const resizeObserver = observeThreeResize( mountRef.current, renderer, camera, ); return () => { resizeObserver.disconnect(); cancelAnimationFrame(animationFrameId); if (mountRef.current && renderer.domElement.parentNode) { mountRef.current.removeChild(renderer.domElement); } renderer.dispose(); geometry.dispose(); corneaMat.dispose(); wireMat.dispose(); }; }, []); return ( <div className="relative w-full h-[500px] md:h-[600px] bg-transparent overflow-hidden rounded-lg group"> <div className="absolute top-4 left-4 z-10 flex flex-col items-start gap-1 pointer-events-none bg-card/80 backdrop-blur-md border border-border p-3 rounded-xl shadow-lg"> <span className="text-[10px] uppercase tracking-wider font-bold text-muted-foreground mb-1 border-b border-border/50 pb-1 w-full text-center"> Topography </span> <div className="flex items-center gap-2 text-xs font-bold text-foreground"> <span className="w-3 h-3 rounded-full border-2 border-foreground" style={{ background: "linear-gradient(to right, blue, red)" }} ></span>{" "} Steeper = redder </div> <div className="text-[10px] text-muted-foreground max-w-[200px] mt-1"> The rings on the right reflect off this same surface. They crowd together where the cornea steepens (red), just like a real topographer. </div> </div> <div className="absolute top-4 right-4 z-20 flex flex-col items-center bg-card/80 backdrop-blur-md border border-border p-3 rounded-xl shadow-lg pointer-events-none"> <span className="text-[10px] uppercase tracking-wider font-bold text-muted-foreground border-b border-border/50 pb-1 w-full text-center mb-2"> Placido Reflex </span> <div className="relative bg-black rounded-full overflow-hidden border border-border/50 shadow-inner w-[130px] h-[130px]"> <canvas ref={canvasRef} width="130" height="130" className="w-[130px] h-[130px] rounded-full block" /> </div> </div> <div ref={mountRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" /> </div> );}
Interact with the preview to capture camera parameters...
Ensamblándolo todo
El modelo final combina todo lo anterior: las seis capas, una forma tórica con sus dos curvaturas representadas en dioptrías clínicas (K=337.5/R en mm), el aplanamiento asférico Q y un espesor ajustable. Los rayos de luz representan un trazado simplificado únicamente a través de la superficie frontal. Muestran cómo el astigmatismo y la asfericidad difuminan un foco, sin pretender ser una simulación óptica completa.
Interact with the preview to capture camera parameters...
Un acabado hiperrealista
Los modelos anteriores están construidos para explicar, por lo que priman los colores claros y esquemáticos frente al fotorrealismo. Para cerrar el ciclo, aquí tienes la córnea vestida de gala: la misma forma asférica, ahora como un menisco transparente (una superficie frontal asférica con una superficie posterior más pronunciada) de un material cristalino transmisivo con un revestimiento transparente, que refracta un entorno de estudio sintético. El reflejo brillante que se desliza por la superficie es el reflejo corneal que los ópticos llaman imagen de Purkinje P1, el mismo reflejo del que dependen los anillos de Plácido. Arrastra para moverlo alrededor de la cúpula.
Hyperrealistic Cornea
Built to average anatomy: Rant 7.8 mm, Rpost 6.5 mm, Q −0.26, thickness 0.55 mm. Glassy transmission with dispersion, a drifting tear-film sheen, and reflections. Drag to orbit.
import React, { useEffect, useRef } from "react";import * as THREE from "three";import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";import { RectAreaLightUniformsLib } from "three/examples/jsm/lights/RectAreaLightUniformsLib.js";import { observeThreeResize } from "../threeResize";// Average anatomical values (mm). Built at true scale, framed by the camera.const R_ANT = 7.8; // anterior radius of curvatureconst Q_ANT = -0.26; // anterior asphericity (prolate)const R_POST = 6.5; // posterior radius (steeper)const Q_POST = -0.4;const CCT = 0.55; // central corneal thicknessconst SEMI = 5.0; // optical-zone semi-diameterfunction sag(r: number, R: number, Q: number) { const c = 1 / R; const root = 1 - (1 + Q) * c * c * r * r; return root >= 0 ? (c * r * r) / (1 + Math.sqrt(root)) : c * r * r;}// Meniscus cornea: aspheric front, steeper back, joined at the rim, lathed into// a closed glassy solid.function corneaLathe() { const steps = 128; const pts: THREE.Vector2[] = []; for (let i = 0; i <= steps; i++) { const r = (i / steps) * SEMI; pts.push(new THREE.Vector2(r, -sag(r, R_ANT, Q_ANT))); } for (let i = steps; i >= 0; i--) { const r = (i / steps) * SEMI; pts.push(new THREE.Vector2(r, -CCT - sag(r, R_POST, Q_POST))); } const geo = new THREE.LatheGeometry(pts, 160); geo.center(); geo.computeVertexNormals(); return geo;}// Surrounding studio world: dark gradient + faint grid on the inside of a big// sphere, visible from any angle and something for the cornea to refract.function worldTexture() { const w = 2048, h = 1024; const c = document.createElement("canvas"); c.width = w; c.height = h; const ctx = c.getContext("2d")!; const g = ctx.createLinearGradient(0, 0, 0, h); g.addColorStop(0, "#0a1512"); g.addColorStop(0.5, "#16241f"); g.addColorStop(1, "#05080a"); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = "rgba(130,180,190,0.15)"; ctx.lineWidth = 2; for (let i = 0; i <= 48; i++) { ctx.beginPath(); ctx.moveTo((i / 48) * w, 0); ctx.lineTo((i / 48) * w, h); ctx.stroke(); } for (let j = 0; j <= 24; j++) { ctx.beginPath(); ctx.moveTo(0, (j / 24) * h); ctx.lineTo(w, (j / 24) * h); ctx.stroke(); } const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; return tex;}// Smooth grayscale noise varying the tear-film thickness (oil-slick bands).function thicknessMap() { const small = document.createElement("canvas"); small.width = small.height = 16; const sctx = small.getContext("2d")!; const img = sctx.createImageData(16, 16); for (let i = 0; i < img.data.length; i += 4) { const v = Math.floor(Math.random() * 255); img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255; } sctx.putImageData(img, 0, 0); const big = document.createElement("canvas"); big.width = big.height = 256; const bctx = big.getContext("2d")!; bctx.imageSmoothingEnabled = true; bctx.drawImage(small, 0, 0, 256, 256); return new THREE.CanvasTexture(big);}export default function CorneaRealistic() { const mountRef = useRef<HTMLDivElement>(null); useEffect(() => { if (!mountRef.current) return; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 200); camera.position.set(6, 4, 18); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setClearColor(0x000000, 0); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.15; mountRef.current.appendChild(renderer.domElement); const pmrem = new THREE.PMREMGenerator(renderer); const envTex = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; scene.environment = envTex; const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.enablePan = false; controls.minDistance = 12; controls.maxDistance = 45; const resizeObserver = observeThreeResize( mountRef.current, renderer, camera, ); const dir = new THREE.DirectionalLight(0xffffff, 2.4); dir.position.set(-6, 9, 12); scene.add(dir); scene.add(new THREE.AmbientLight(0xffffff, 0.25)); // Softbox-style light: reflects off the wet surface as a crisp catchlight. RectAreaLightUniformsLib.init(); const softbox = new THREE.RectAreaLight(0xffffff, 6, 7, 4); softbox.position.set(-7, 8, 11); softbox.lookAt(0, 0, 0); scene.add(softbox); // Surrounding world. const worldTex = worldTexture(); const world = new THREE.Mesh( new THREE.SphereGeometry(60, 48, 48), new THREE.MeshBasicMaterial({ map: worldTex, side: THREE.BackSide }), ); scene.add(world); const geo = corneaLathe(); const filmMap = thicknessMap(); filmMap.wrapS = filmMap.wrapT = THREE.RepeatWrapping; const mat = new THREE.MeshPhysicalMaterial({ color: 0xffffff, transmission: 1.0, thickness: CCT, ior: 1.376, // Chromatic dispersion: rainbow fringing at refracted edges. dispersion: 0.4, roughness: 0.015, metalness: 0.0, // Wet surface reflection. clearcoat: 1.0, clearcoatRoughness: 0.015, specularIntensity: 1.0, envMapIntensity: 1.3, // Tear-film thin-film interference (oil-slick sheen). iridescence: 1.0, iridescenceIOR: 1.32, iridescenceThicknessRange: [80, 420], iridescenceThicknessMap: filmMap, // A touch of anisotropy from the corneal collagen grain. anisotropy: 0.2, transparent: true, attenuationColor: new THREE.Color(0xd6ecff), attenuationDistance: 12, side: THREE.DoubleSide, }); const cornea = new THREE.Mesh(geo, mat); cornea.rotation.x = -Math.PI / 2; // axis toward the camera (+Z) scene.add(cornea); let animationFrameId: number; const animate = () => { animationFrameId = requestAnimationFrame(animate); // Slowly drift the tear-film thickness so the sheen moves like tears // spreading across the surface after a blink. filmMap.offset.x += 0.0006; filmMap.offset.y += 0.0003; controls.update(); renderer.render(scene, camera); const debugEl = mountRef.current ?.closest(".interactive-viewer") ?.querySelector(".debug-output"); if (debugEl) { const state = { camera: { position: { x: +camera.position.x.toFixed(3), y: +camera.position.y.toFixed(3), z: +camera.position.z.toFixed(3), }, }, target: { x: +controls.target.x.toFixed(3), y: +controls.target.y.toFixed(3), z: +controls.target.z.toFixed(3), }, }; (debugEl as HTMLElement).innerText = JSON.stringify(state, null, 2); } }; animate(); return () => { cancelAnimationFrame(animationFrameId); resizeObserver.disconnect(); if (mountRef.current && renderer.domElement.parentNode) { mountRef.current.removeChild(renderer.domElement); } geo.dispose(); mat.dispose(); filmMap.dispose(); worldTex.dispose(); world.geometry.dispose(); (world.material as THREE.Material).dispose(); envTex.dispose(); pmrem.dispose(); renderer.dispose(); }; }, []); return ( <div className="relative w-full h-[500px] md:h-[600px] bg-transparent overflow-hidden rounded-lg group"> <div className="absolute top-4 left-4 z-10 flex flex-col items-start gap-1 pointer-events-none bg-card/80 backdrop-blur-md border border-border p-3 rounded-xl shadow-lg"> <span className="text-[10px] uppercase tracking-wider font-bold text-muted-foreground mb-1 border-b border-border/50 pb-1 w-full text-center"> Hyperrealistic Cornea </span> <div className="text-[10px] text-muted-foreground max-w-[220px] leading-relaxed"> Built to average anatomy: R<sub>ant</sub> 7.8 mm, R<sub>post</sub> 6.5 mm, Q −0.26, thickness 0.55 mm. Glassy transmission with dispersion, a drifting tear-film sheen, and reflections. Drag to orbit. </div> </div> <div ref={mountRef} className="w-full h-full cursor-grab active:cursor-grabbing" /> </div> );}
Interact with the preview to capture camera parameters...
Eso nos da una superficie frontal sólida para el ojo.
Referencias
Young, T. (1801). On the Mechanism of the Eye. Philosophical Transactions of the Royal Society of London, 91, 23-88.
Helmholtz, H. von (1854). Ueber die Accommodation des Auges. Graefes Archiv für Ophthalmologie, 1(2), 1-74.
Atchison, D. A., & Smith, G. (2000). Optics of the Human Eye. Butterworth-Heinemann, Oxford, Capítulo 2.
Maurice, D. M. (1957). The structure and transparency of the cornea. Journal of Physiology, 136(2), 263-286.
Benedek, G. B. (1971). Theory of transparency of the eye. Applied Optics, 10(3), 459-473.
Dua, H. S., Faraj, L. A., Said, D. G., Gray, T., & Lowe, J. (2013). Human Corneal Anatomy Redefined: A Novel Layer (Dua’s Layer). Ophthalmology, 120(9), 1778-1785.
Plácido, A. (1880). Novo instrumento para analyse da curvatura da cornea. Periodico Ophthalmologico, 2(5), 44-49, Lisboa.