Three.js masasındaki iPhone'a canlı bir DOM ekranı yerleştirmek
iPhone klonumda telefonun ekranı, 3D modelin içine oturtulmuş gerçek bir React ağacı. O ekranı modele nasıl yerleştirdiğimi, kamera dönerken yazıların neden titrediğini ve telefonlarda 3D sahnenin neden hiç yüklenmediğini anlatıyorum.
Bu projede bir iPhone 15 Pro Max, Three.js ile çizdiğim bir çalışma masasında alüminyum bir standa yaslanmış duruyor. Ekranında React ile yazdığım, iOS 26'nın Liquid Glass görünümünü izleyen bir arayüz ve 16 uygulama çalışıyor. Bu yazı yalnızca o ekranla ilgili. O ekran bir doku değil, sahneye yerleştirilmiş gerçek bir DOM ağacı: tıklayabiliyor, içine yazı yazabiliyorsun.
Karşılaştırmak için aynı sahnede hazır bir örnek de var. Telefonun arkasındaki MacBook'ta bir editör açık, editörde de kabaca bu sahnenin kodu yazıyor. O ekranı src/scene/laptopScreen.ts bir 2D canvas'a bir kez çiziyor, Laptop.tsx de sonucu CanvasTexture olarak modelin ekran mesh'ine basıyor. Uzaktan inandırıcı ama tıklayınca hiçbir şey olmuyor. Telefonda bunun tersini istedim: kilit ekranını kaydırabilmeni, Notlar'a yazı yazabilmeni, Kontrol Merkezi'ndeki kaydırıcıyı sürükleyebilmeni.
Git geçmişi bu hikâyede pek yardım etmiyor, depo tek bir commit'ten ibaret. Aşağıdaki "burada bir şey ters gitti" anlarını koddaki yorumlardan topladım. Neyse ki o yorumların çoğu bir satırın neden öyle yazıldığını anlatıyor.
Modelin ekranını söküp yerine DOM koymak
Repodaki planlama notuna (docs/iphone-clone-agent-prompt.md) göre telefon prosedürel kurulacaktı: drei'nin RoundedBox'ından bir gövde, clearcoat'lu bir ön cam ve dışarıdan hiç GLB yok. Telefon boştayken de hafifçe süzülecekti. İkisi de öyle kalmadı. Gövde şimdi polyman'ın Sketchfab'de CC BY 4.0 ile paylaştığı iPhone 15 Pro Max modeli. Telefon da standa yaslı ve onu yalnızca belirgin haptikler kısa bir titreşimle sarsıyor. Hafif dokunuşlar onu kıpırdatmıyor, çünkü src/lib/haptics.ts'teki yorumun dediği gibi her dokunuşta sallanan bir telefon titreme gibi görünüyor. Plandan değişmeden kalan satır ekranınkiydi: drei'nin <Html transform occlude="blending"> bileşeni, 390×844 mantıksal piksel.
Modelden üç mesh'i gizliyorum: ekranın kendisi, Dynamic Island hapı ve onun içindeki kamera noktası (src/scene/IPhone.tsx içindeki HIDDEN kümesi). Üçünün yerini DOM ekran alıyor. Dynamic Island da artık arayüzün bir parçası, genişleyip daralan bir React bileşeni.
Sahnede bir birim yaklaşık bir santimetre ve modeldeki ekran 7,128 birim genişliğinde. src/scene/geometry.ts bir CSS pikselinin sahnede kaç birim tuttuğunu PX = SCREEN.w / 390 olarak saklıyor. drei, transform modunda bir CSS pikselini distanceFactor / 400 birim sayıyor. Ben de distanceFactor'a PX * 400 veriyorum ve 390 piksellik arayüz camın bir kenarından öbürüne tam oturuyor. Ekranın yüksekliği (7.128 * 844 / 390) ve 55 piksellik köşe yarıçapı da aynı orandan çıkıyor. Bileşenin src/scene/Phone.tsx içindeki hali şöyle:
<Html
ref={layer}
wrapperClass={SCREEN_LAYER}
transform
occlude="blending"
geometry={<shapeGeometry args={[screenHole, 24]} />}
distanceFactor={PX * 400}
// Clear of the front glass by more than depth precision at max orbit distance, or the glass z-fights through.
position-z={PHONE.d / 2 + 0.01}
zIndexRange={[10, 0]}
// A fixed portal: the default target switches once R3F connects events, remounting the screen root.
portal={portal}
>
{screen}
</Html>geometry olarak verdiğim şekil, ekranla aynı yuvarlatılmış dikdörtgen. Birazdan anlatacağım delik bu şekille açılıyor. position-z satırındaki yorum da bir pürüzü anlatıyor. Deliği açan mesh modelin ön camına fazla yakın durunca, kamera en uzak yörüngedeyken derinlik tamponunun hassasiyeti yetmiyor ve cam ekranın içinden kırpışarak görünüyor. 0,01 birimlik payı bu yüzden bıraktım.
Canvas üstte, DOM altında
drei'nin <Html>'i normalde canvas'ın üstünde durur. occlude="blending" bu sırayı tersine çeviriyor. drei canvas'ı position: absolute yapıyor, ona zIndexRange'in ortasını (burada 5) ve pointer-events: none veriyor. DOM katmanı ise 0 ile 4 arasında bir z-index alıp canvas'ın altına iniyor. Ekran şeklindeki mesh, canvas'a piksellerini tamamen saydam yazıyor. Sahnede ekran büyüklüğünde bir pencere açılıyor ve alttaki DOM oradan görünüyor. Canvas fare olaylarını almadığı için ekrandaki tıklamaların doğrudan DOM'a düşüyor. Ekranın dışındaki sürüklemeler ise R3F'in canvas'ı saran div'ine gidip kamerayı döndürüyor.
Canvas üstte olduğu için ekranın üstüne ışık da çizebiliyorum. Telefonun ön camını DOM ekranın biraz önünde, siyah bir mesh olarak çiziyorum. Bu mesh yalnızca yansıma ekliyor: lambanın ve odanın parlamasını, ekrana yandan bakınca da Fresnel'i. İşin püf noktası blending ayarında, src/scene/geometry.ts:
/**
* Material props for glass that only adds reflections: colour is summed onto what's behind, alpha is left
* untouched. Plain AdditiveBlending also writes alpha, which would turn the screen's see-through hole opaque black.
*/
export const REFLECTION_ONLY = {
transparent: true,
depthWrite: false,
blending: CustomBlending,
blendSrc: OneFactor,
blendDst: OneFactor,
blendSrcAlpha: ZeroFactor,
blendDstAlpha: OneFactor,
} as const;Renk kanallarında kaynakla hedef toplanıyor, alfa kanalında hedef olduğu gibi kalıyor. Delikteki alfa sıfır olduğu için tarayıcı bu renkleri alttaki DOM'un üstüne ışık gibi ekliyor. Yorum, three.js'in hazır AdditiveBlending ayarının burada neden işe yaramadığını da söylüyor: o ayar alfayı da yazdığı için saydam deliği opak siyaha çevirirdi.
Ekranın parlaklığı hem DOM'u hem sahneyi etkiliyor. Kontrol Merkezi'ndeki kaydırıcı store'daki brightness değerini değiştiriyor. Shell.tsx bu değeri DOM'a filter: brightness() olarak uyguluyor, Phone.tsx de aynı değerle ekranın altındaki soğuk renkli pointLight'ın şiddetini ayarlıyor ve ışık stand ile masaya vuruyor. Işığı ekranın altına koydum, çünkü yorumuna göre camdaki yansıması o konumda ekranın dışına düşüyor.
Kamera dönerken titreyen yazılar
Kamerayı döndürünce ekrandaki yazılar titriyordu. Sebebi src/scene/Phone.tsx'teki yorumda yazılı: Chrome, CSS ile 3D dönüştürülmüş katmanı kamera her hareket ettiğinde yeni ölçeğinde yeniden rasterleştiriyor. Harfler her karede başka bir piksel ızgarasına oturuyor ve metin kıpırdıyor.
İki ucun da bedeli var. Katmanın rasterini sabitlersen hareket akıcı olur ama görüntü biraz yumuşar. Her karede yeniden rasterleştirirsen her kare keskin olur ama titrer. Ben ikisinin arasında bir yol seçtim: kamera hareket ettiği sürece katmana will-change: transform veriyorum, kamera durduktan 180 ms sonra kaldırıyorum. Kod yine src/scene/Phone.tsx içinde:
/** Quiet time after the last camera move before the screen is re-rasterised crisp. */
const SETTLE_MS = 180;
/**
* Chrome re-rasterises the CSS3D screen at every new scale while the camera moves, so glyphs snap to a
* different pixel grid each frame and text shimmers. `will-change: transform` pins the raster during
* motion (smooth, slightly soft); dropping it once the camera rests gives one crisp raster at the final scale.
*/
function usePinnedRasterWhileMoving(layer: RefObject<HTMLDivElement | null>) {
const last = useRef(new Matrix4());
const idle = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(idle.current), []);
useFrame(({ camera }) => {
const el = layer.current;
if (!el || last.current.equals(camera.matrixWorld)) return;
last.current.copy(camera.matrixWorld);
el.style.willChange = "transform";
clearTimeout(idle.current);
idle.current = setTimeout(() => (el.style.willChange = ""), SETTLE_MS);
});
}Kameranın hareket edip etmediğini anlamak için her karede camera.matrixWorld'ü bir önceki kareninkiyle karşılaştırıyorum. Zamanlayıcı şart, çünkü sahne frameloop="demand" ile çalışıyor ve R3F yalnızca biri invalidate() çağırdığında kare çiziyor. Kamera durunca kare de gelmiyor, yani useFrame içinde "kamera durdu" anını yakalayacak bir kare yok. Son hareketten 180 ms sonra çalışan setTimeout will-change'i kaldırıyor, tarayıcı da ekranı son ölçekte bir kez ve keskin olarak çiziyor.
180 sayısının kodda bir gerekçesi yok, sabitin yorumu yalnızca ne işe yaradığını söylüyor. Bedelini ise yorum açıkça yazıyor: hareket sürdükçe ekran biraz yumuşak kalıyor.
Dokunmak, sürüklemek ve ikinci React kökü
Ekran gerçek DOM olunca etkileşimin büyük kısmı bedavaya geliyor, açıkçası bu yaklaşımda en sevdiğim yan da bu. Düğmeler button, Notlar'daki metin alanı textarea. Odak, klavye ve metin seçimi tarayıcının işi. 3D dönüşüm uygulanmış bir öğede bile tıklamanın hangi öğeye düştüğünü tarayıcı kendisi hesaplıyor.
Hareketlerde işler biraz farklı. @use-gesture sürükleme miktarını ekran pikseli olarak veriyor, benim eşiklerim ise telefonun mantıksal pikseli cinsinden. Aradaki çeviriyi src/lib/util.ts'teki screenScale yapıyor: ekran kökünün getBoundingClientRect() yüksekliğini 844'e bölüyor. Kenar kaydırmaları, uygulama değiştirici ve Kontrol Merkezi de hareket miktarını bu orana bölüp kullanıyor. Düz modda da 3D'de de aynı fonksiyon çalışıyor.
Çift tıklarsan kamera ekrana yaklaşıyor. Bunun dinleyicisini sahneyi saran div'e koydum, çünkü ekranın DOM'u canvas'ın içinde durmuyor, aynı div'in içinde onun yanına ekleniyor. Dinleyici src/scene/Controls.tsx içinde:
// On the stage, not the canvas: the screen DOM is portalled beside the canvas, not inside it.
useEffect(() => {
const el = stage.current;
const onDoubleClick = (e: MouseEvent) => {
const { width, height } = get().size;
const { home, focus } = shots(width / height);
// From the screen it only ever zooms in, so a quick double tap inside an app never throws the camera back.
const fromScreen = e.target instanceof Element && e.target.closest(`.${SCREEN_LAYER}`);
const focused = camera.position.distanceTo(focus.position) < 0.5;
tween.current = focused && !fromScreen ? home : focus;
invalidate();
};
el.addEventListener("dblclick", onDoubleClick);
return () => el.removeEventListener("dblclick", onDoubleClick);
}, [camera, invalidate, stage, get]);SCREEN_LAYER sınıfı, Html'in sarmalayıcısına verdiğim phone-screen. Ekranın içinden gelen çift tıklama yalnızca yakınlaştırıyor. Bunu, bir uygulamada iki kez hızlıca dokunduğunda kamera geri savrulmasın diye yaptım.
drei'nin <Html>'i içeriğini kendi createRoot'uyla ayrı bir React kökünde çiziyor. Koddaki iki yorum daha, bu kökün beklenmedik anlarda yeniden kurulduğunu anlatıyor. İlki portal meselesi. portal vermeyince drei hedef olarak önce canvas'ın ebeveynini, R3F olayları bağladıktan sonra da olay kaynağı olan div'i kullanıyor. Hedef değişince kök sökülüp yeniden kuruluyor ve ekran sıfırlanıyor. Ben de portal'ı sahnenin sabit div'ine bağladım. İkincisi src/main.tsx'in son satırlarında: StrictMode'un çift effect çalıştırması bu iç kökü yeniden bağlandıktan sonra asenkron olarak söküyor ve telefonun ekranı boş kalıyor. Uygulamada StrictMode yok, nedeni de hemen üstündeki yorumda yazıyor.
Telefonda 3D sahne hiç inmiyor
Planlama notunda bir cümle daha vardı: mobil cihazlarda düz mod varsayılan olacak. src/os/store.ts bunu tek satırla yapıyor: (hover: none) and (pointer: coarse) eşleşiyorsa flat başlangıçta true. Masaüstünde F tuşuyla iki mod arasında geçebilirsin.
Düz modun en büyük kazancı yükleme tarafında. src/main.tsx 3D sahneyi lazy() ile içe aktarıyor. Build --splitting ile yapıldığı için three.js, R3F ve drei ayrı bir parçaya düşüyor. Düz modda src/main.tsx Scene'i hiç render etmediği için o parça hiç istenmiyor:
// three.js stays out of the bundle on phones, where flat mode is the default.
const Scene = lazy(() => import("./scene/Scene"));
// ...
const fitScale = () => Math.min(innerWidth / 390, innerHeight / 844) * (matchMedia("(pointer: coarse)").matches ? 1 : 0.94);
// ...
function Root() {
const flat = useOS(s => s.flat);
useShortcuts();
return flat ? (
<FlatView />
) : (
<Suspense fallback={null}>
<Scene />
</Suspense>
);
}FlatView 390×844'lük ekranı pencereye sığacak şekilde CSS scale ile büyütüp küçültüyor. Dokunmatik cihazda ekranı tam sığdırıyor, masaüstünde yüzde 6 pay bırakıyor. Düz mod da 3D sahne de aynı <Shell />'i çiziyor, değişen yalnızca etrafındaki çerçeve. İşletim sisteminin durumu React ağacının dışında, zustand store'unda duruyor. Bu yüzden F'ye bastığında kabuk yeniden mount ediliyor ama açık uygulama ve kilit durumu yerinde kalıyor.
3D modda da ekran modelleri beklemiyor. Scene.tsx'te masa, lamba, dekor ve oda kendi Suspense sınırının içinde. Phone.tsx'teki Suspense ise yalnızca telefonun gövdesini sarıyor. GLB dosyaları inerken DOM ekran sahnede çalışır halde duruyor.
frameloop="demand"'in burada da bir yan etkisi var: model yüklendiğinde kimse invalidate() çağırmazsa yeni kare çizilmiyor ve model ekrana gelmiyor. RevealFrame adlı fallback bileşeni bu yüzden var. Suspense onu modeli gösterdiği commit'te kaldırıyor, bileşenin temizleme fonksiyonu da tam o anda bir kare istiyor.
Hâlâ eğreti duran yerler
Bugün koda bakınca en zayıf bulduğum yer src/lib/util.ts'teki screenScale:
/** Logical-px per screen-px ratio of the phone screen, which may be scaled or in 3D. */
export function screenScale(el: Element | null): number {
const root = el?.closest("[data-screen]");
return root ? root.getBoundingClientRect().height / 844 : 1;
}
/** Centre of `el` in logical screen px; the app open/close animation grows from here. */
export function originOf(el: Element): { x: number; y: number } {
const screen = el.closest("[data-screen]")?.getBoundingClientRect();
const rect = el.getBoundingClientRect();
if (!screen) return { x: 195, y: 422 };
const k = screen.width / 390;
return { x: (rect.left + rect.width / 2 - screen.left) / k, y: (rect.top + rect.height / 2 - screen.top) / k };
}Bu oran düz modda ve kamera ekrana dik bakarken doğru çıkıyor. Kamerayı yana çevirince ekranın sınırlayıcı kutusu daralıyor, yüksekliği ise pek değişmiyor. Yatay hareket olduğundan kısa ölçülüyor, perspektif de hesabı bozuyor. Üstelik originOf aynı işi genişlikten, screenScale yükseklikten yapıyor, bu yüzden eğik bir bakışta ikisi farklı sonuç veriyor. Doğrusu, işaretçi koordinatlarını ekranın CSS matrisinin tersinden geçirip gerçekten mantıksal piksele çevirmek olurdu.
Titreme çözümünü Chrome'un davranışına göre yazdım, yorum başka tarayıcılardan hiç söz etmiyor. Liquid Glass'taki kırılma efekti de backdrop-filter: url(#lg) gerektirdiği için yalnızca Chromium'da çiziliyor. Glass.tsx diğer tarayıcılarda düz bir bulanıklıkla yetiniyor. Testler hareket eşiklerini, haptik desenlerini, kilit ekranındaki "sıradaki" hesabını, hesap makinesini ve takvim tarihlerini kapsıyor ama sahne tarafında hiç test yok.
Tarayıcıda yazdığım öbür işletim sistemleriyle bu projenin karşılaştırmasını şu yazıda yaptım.
- Proje sayfası
- Kaynak kod;
bun installvebun devile yerelde çalıştırabilirsin.
