<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[g.a.d. — 3D websites]]></title><description><![CDATA[g.a.d. — 3D websites]]></description><link>https://gadwebsites.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>g.a.d. — 3D websites</title><link>https://gadwebsites.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 08 Oct 2026 14:33:04 GMT</lastBuildDate><atom:link href="https://gadwebsites.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I checked 4,497 small-business websites in 36 minutes. 125 were losing customers and nobody had noticed]]></title><description><![CDATA[On 6 October I ran a check on 4,497 websites of small businesses: clinics, repair shops, builders, beauty salons, local stores. The list came from public business directories, so most of the companies]]></description><link>https://gadwebsites.hashnode.dev/i-checked-4-497-small-business-websites-in-36-minutes-125-were-losing-customers-and-nobody-had-noticed</link><guid isPermaLink="true">https://gadwebsites.hashnode.dev/i-checked-4-497-small-business-websites-in-36-minutes-125-were-losing-customers-and-nobody-had-noticed</guid><category><![CDATA[webdev]]></category><category><![CDATA[SEO]]></category><category><![CDATA[Small business]]></category><category><![CDATA[websites]]></category><dc:creator><![CDATA[Artur Smirnov (g.a.d.)]]></dc:creator><pubDate>Tue, 06 Oct 2026 18:18:39 GMT</pubDate><content:encoded><![CDATA[<p>On 6 October I ran a check on 4,497 websites of small businesses: clinics, repair shops, builders, beauty salons, local stores. The list came from public business directories, so most of the companies are in Russia, but nothing below is specific to one country. The run took 36 minutes and about two requests per site.</p>
<p>I wasn't grading design or page speed. I was looking for one kind of problem only: the kind that loses a customer today. Someone already found the business, clicked through, and hit a wall.</p>
<p>The first pass flagged 200 sites. I checked every one of them again by a different route, and for sites that looked down, from three monitoring nodes inside the country, because some sites block foreign traffic. 156 held up. Then I removed subdomains of big chains and landing pages that hide from search on purpose. That left 125 company main sites with a real, working-hours problem. About 1 in 36.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6abcabe79eb621c713380b33/8e833757-0544-4803-9f9a-e7ff692d6275.png" alt="125 of 4,497 small-business websites had a problem that loses customers" /></p>
<h2>Hidden from search: 36 sites</h2>
<p>The site works, the owner opens it every day, and search engines are told not to show it. Either the home page carries a <code>noindex</code> tag, or robots.txt says this:</p>
<pre><code>User-agent: *
Disallow: /
</code></pre>
<p>This is almost always left over from development. A developer closes the site from search while it's being built, launches it, and forgets to open it again. Of the cases I looked at closely, about 6 in 10 were robots.txt and the rest were a meta tag or an <code>X-Robots-Tag</code> header.</p>
<p>The owner has no reason to notice. They reach the site from a bookmark or by typing the address. New customers searching for the service never see it.</p>
<h2>The site doesn't open: 31 sites</h2>
<p>Server errors, a domain that no longer resolves, or a timeout, on every version of the address: with and without www, http and https. Directory listings, maps and old ads keep sending people there. If you pay for a listing or for clicks, you're paying for visits that end on an error page.</p>
<h2>The phone link goes to a 404: 27 sites</h2>
<p>This one surprised me the most. On a phone, the most valuable thing on a small-business site is the number you can tap to call. On 27 sites the tap opens "page not found" instead of the dialer. The link looks like this:</p>
<pre><code class="language-html">&lt;a href="/+15550100"&gt;+1 555-0100&lt;/a&gt;
</code></pre>
<p>instead of this:</p>
<pre><code class="language-html">&lt;a href="tel:+15550100"&gt;+1 555-0100&lt;/a&gt;
</code></pre>
<p>Without the <code>tel:</code> prefix the browser treats the number as a page on the site, and that page doesn't exist. It is easy to do in a site builder, when someone pastes the number into a link field. On a desktop nobody clicks the number, so the owner never sees it break.</p>
<h2>Domain expired, parked or for sale: 23 sites</h2>
<p>Instead of the site there's a hosting placeholder: "domain expired", "account suspended", "this domain is for sale". Business cards, signs and directory listings still point to it. Every customer who types the address sees a business that looks closed, and if the domain stays unpaid long enough, someone else can buy it.</p>
<h2>Certificate warning: 7 sites</h2>
<p>Expired, issued for a different domain, or self-signed. The browser shows a full-page warning before the site, and most people close the tab right there. Certificates are free now and renew on their own when set up properly, so this is usually a renewal that silently stopped working.</p>
<h2>A form that sends without https: 1 site</h2>
<p>One contact form on an https site sent requests to a bare IP address over plain http. Chrome warns the visitor that the form isn't secure as they submit it, and many people stop right there. Only one in this run, but it sits exactly where a customer is already writing to you.</p>
<h2>Why owners don't see this</h2>
<p>None of these problems break loudly. The site looks fine from the office computer, from the bookmark, on a desktop. The phone link only fails on a phone. The search block only shows in search. The expired domain is noticed when calls drop, and by then it's been weeks.</p>
<p>That's also why I didn't count slow pages, missing mobile versions or certificates that expire next week. Those matter, but they aren't losing anyone a customer today.</p>
<h2>How the check worked</h2>
<p>At most 5 requests per site, 2 on average: robots.txt, the home page, up to three variants of the address, or the link behind the phone number. At least one second between requests to the same site, a 10-second timeout, 16 sites at a time, and robots.txt rules respected. A problem only counted after the second check confirmed it.</p>
<h2>Check your own site in a minute</h2>
<p>You can do the most important part by hand:</p>
<ol>
<li>Open yoursite.com/robots.txt. If you see <code>Disallow: /</code> with nothing else allowed, your site is hidden from search.</li>
<li>Open the site on your phone and tap your phone number. It should open the dialer.</li>
<li>Open the address with and without www, and with http and https. All four should land on the same working page without a warning.</li>
<li>Look up when your domain and hosting expire, and turn on auto-renewal.</li>
<li>Send yourself a message through your own contact form.</li>
</ol>
<p>Or paste your address into the free check I made: <a href="https://gad-production.ru/proverka/en/">https://gad-production.ru/proverka/en/</a>. It goes through up to 25 pages, shows hidden pages, broken links, phone numbers that can't be tapped, missing https redirects and the rest, and it doesn't store your address. No sign-up.</p>
<p>If it finds something and you'd rather not fix it yourself, message me on Telegram <a href="https://t.me/smirnovarturr">https://t.me/smirnovarturr</a> or email <a href="mailto:paladei702@gmail.com">paladei702@gmail.com</a>. Artur Smirnov, g.a.d.</p>
]]></content:encoded></item><item><title><![CDATA[Ray-tracing a diamond in a three.js shader: a 3D ring you can take apart]]></title><description><![CDATA[This is a concept site for a jewellery brand. It shows one engagement ring, a one-carat round brilliant on a gold band, and there is no photo anywhere on the page. The ring is geometry and shaders: yo]]></description><link>https://gadwebsites.hashnode.dev/ray-tracing-a-diamond-in-a-three-js-shader-a-3d-ring-you-can-take-apart</link><guid isPermaLink="true">https://gadwebsites.hashnode.dev/ray-tracing-a-diamond-in-a-three-js-shader-a-3d-ring-you-can-take-apart</guid><category><![CDATA[GLSL]]></category><category><![CDATA[ThreeJS]]></category><category><![CDATA[WebGL]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Artur Smirnov (g.a.d.)]]></dc:creator><pubDate>Wed, 30 Sep 2026 11:09:06 GMT</pubDate><content:encoded><![CDATA[<p><img src="https://cdn.hashnode.com/uploads/covers/6abcabe79eb621c713380b33/8ec3b278-ce10-4dca-8305-53c192fcea52.jpg" alt="The g.a.d. ring: a round brilliant in six claws on an 18k gold band" /></p>
<p>This is a concept site for a jewellery brand. It shows one engagement ring, a one-carat round brilliant on a gold band, and there is no photo anywhere on the page. The ring is geometry and shaders: you can drag it, scroll through it, take it apart, change the metal, the cut and the carat weight, and the price follows.</p>
<p>It's one <code>index.html</code>: three.js 0.170 and Lenis from a CDN, no build step, no model files, no image textures. The stone, the band, the claws and the studio light are generated in code when the page opens.</p>
<p>Live: <a href="https://smirnov-artur.github.io/demo/gad-ring/">https://smirnov-artur.github.io/demo/gad-ring/</a></p>
<h2>The stone is real geometry</h2>
<p>Most web diamonds are a glass material on a low-poly mesh, and they look like glass. A diamond gets its look from proportions: light enters through the top, bounces off the angled pavilion facets and comes back out the top. Get the angles wrong and the light leaks out the bottom, and the stone goes dark. So I started from the numbers a cutter works with:</p>
<pre><code class="language-js">// round brilliant, girdle diameter 1: table 56 %, crown 34.5°, pavilion 40.75°, star 50 %, lower girdle 77 %
const P = [], R = .5, rt = .28, gt = .011, c8 = Math.cos(Math.PI / 8);
const hc = gt + (R - rt) * Math.tan(34.5 * DEG), hp = gt + R * Math.tan(40.75 * DEG);
</code></pre>
<p>From these the function places the table corners, star points, lower girdle points and the culet, and hands them to <code>ConvexGeometry</code>. Two details took longer than everything else. The star points are computed so every kite facet stays flat. And the girdle has 32 points, every second of which sits on its facet plane, so the girdle edge scallops like a real one instead of being a clean circle. Total depth comes out at 60.4 %, inside the range cutters aim for.</p>
<p>After the hull is built, its triangles are merged back into facet planes: same normal, same offset, one plane. The round brilliant ends up with 89 of them, the classic 57 facets plus 32 girdle faces. The oval is the same points stretched 0.82 × 1.24, and the princess has its own point list with a step crown and a chevron pavilion.</p>
<h2>Tracing light inside the stone</h2>
<p>The planes go into the shader as a <code>vec4</code> uniform array, normal and distance. The stone is convex, and that makes tracing cheap: from any point inside, the facet the ray exits through is simply the nearest plane in front of it. No BVH, no triangle tests, one loop over planes. Slightly trimmed:</p>
<pre><code class="language-glsl">for (int b = 0; b &lt; BOUNCE; b++) {
  float tm = 1e5; vec3 nh = vec3(0., 1., 0.);
  for (int i = 0; i &lt; MAXP; i++) {    // the stone is convex: nearest facet plane ahead is the exit
    if (i &gt;= np) break;
    vec4 pl = planes[i]; float dn = dot(pl.xyz, d);
    if (dn &gt; 1e-5) { float t = (pl.w - dot(pl.xyz, p)) / dn; if (t &lt; tm) { tm = t; nh = pl.xyz; } }
  }
  p += d * tm;
  float c = dot(d, nh), s2 = ior * ior * (1. - c * c);
  vec3 rf = reflect(d, -nh);
  if (s2 &lt; 1.) {                      // not total internal reflection: some light leaves here
    float Fo = fres(sqrt(1. - s2), r0);
    vec3 dr = refract(d, -nh, iv.r), dg = refract(d, -nh, iv.g), db = refract(d, -nh, iv.b);
    // ...one environment lookup per channel...
    col += thr * (1. - Fo) * o;
    thr *= Fo;
  }
  d = rf;
}
</code></pre>
<p>Per pixel it goes like this. On the surface, Schlick's Fresnel with IOR 2.42 gives about 17 % reflection head-on: that's the polish on the facet. The rest refracts inside. At every wall the shader checks for total internal reflection, and with an IOR that high the critical angle is only about 24°, so most rays bounce. Where light does get out, it is refracted three times, once per colour channel, with IORs of 2.375, 2.42 and 2.4875. Red bends least, blue most, and that split is the fire, the coloured flashes. The spread is wider than a real diamond's: at the size a stone takes on screen, the physical value barely shows. The reflected part goes on to the next bounce, 7 of them on desktop and 4 on phones.</p>
<p>All of it runs in the stone's own object space. Each frame, <code>onBeforeRender</code> moves the camera position into the gem's local space and builds one matrix, the environment rotation times the gem's rotation with scale removed, so the 89 planes never get transformed.</p>
<p>What the stone reflects matters as much as the tracing. The environment is a small scene built in code: a dark dome, long softboxes for the metal, a lime kicker in the brand colour and 64 small hard "pin lights" with intensities up to 15. The metal gets that scene through <code>PMREMGenerator</code>, which prefilters and slightly softens it. The stone samples an unfiltered 512 px half-float cube map of the same scene (256 px on phones), because softening would wash out the pin lights, and pin lights are what a diamond breaks into sparkle. The studio turns slowly with scroll, time and the cursor, so the facets keep flashing even when the ring stands still.</p>
<p>On top sits one extra draw call: a point sprite on each of the 32 crown facets. Its vertex shader mirrors the view direction off the facet, looks up the same environment and only lights up when that reflection hits something bright. The fragment shader draws a small four-armed star, the glint a photographer gets from a hard light.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6abcabe79eb621c713380b33/6565cea4-18e0-411a-88b1-03873d6fce6a.jpg" alt="Frames from the page: the hero, the stone close-up, the configurator" /></p>
<h2>Metal: platinum is not white gold</h2>
<p>The metal is a stock <code>MeshPhysicalMaterial</code> with <code>metalness: 1</code> and three presets:</p>
<pre><code class="language-js">const METALS = [
  { color: 0xffc766, rough: .09, base: 690 },   // 18k yellow gold
  { color: 0xf7b196, rough: .09, base: 690 },   // 18k rose gold
  { color: 0xd9dbde, rough: .07, base: 1090 },  // platinum 950
];
</code></pre>
<p>The third is platinum 950, not white gold, and the ring knows it. Inside the band there's an engraving (the brand, a hallmark and "N°03 · 1/1") drawn on a canvas and used as an alpha map on a thin strip that follows the inner surface. The hallmark reads "750 · AU" for both golds and "PT 950" for platinum, and the texture is rebuilt only when the hallmark actually changes. Switching metals blends colour and roughness over 0.8 s instead of snapping.</p>
<p>The band is a cross-section swept around the finger: flat outside, slightly domed inside like a comfort-fit band, wider and thicker under the stone. Each claw is a <code>TubeGeometry</code> along a Catmull-Rom curve from the collar up the pavilion, over the girdle and onto the crown: six for round and oval, four for princess. The princess gallery rail is a <code>TorusGeometry</code> with four radial segments, which is to say a square.</p>
<h2>Configurator and price</h2>
<p>Price is metal plus stone:</p>
<pre><code class="language-js">const stone = 5600 * Math.pow(cfg.ct, 1.85) * SHAPES[cfg.shape].k, base = METALS[cfg.metal].base;
</code></pre>
<p>The exponent is the point. Diamond prices grow faster than weight, so a 2 ct stone costs about 3.6 times a 1 ct one, not twice. An oval is 0.92 of a round's price, a princess 0.8. A 1.00 ct round in yellow gold shows $6,290, split as "setting $690 · stone $5,600". The number goes through <code>Intl.NumberFormat</code> with rounding to a step, and the Russian version of the page swaps only a small block of words, currency and rate.</p>
<p>The carat slider scales the whole head by <code>0.76 · ∛ct</code>, because weight follows volume and diameter follows its cube root: a 1 ct round is 6.5 mm across, a 2 ct is 8.2 mm, not 13. Changing the cut shrinks the stone away, rebuilds the geometry at the halfway point and grows it back, so nothing pops.</p>
<h2>The film on scroll</h2>
<p>There's no timeline library. Each section has a pose for the ring: position, scale, three rotations, an explode value, a pivot and a spin speed, with a separate set for phones. <code>layout()</code> reads the real section offsets from the DOM and pins the poses to scroll positions, and between two keys the pose is eased. On top of that, every frame the current pose chases the target by <code>1 − exp(−dt·7)</code>, so the motion is the same at 60 Hz and 144 Hz.</p>
<p>The pivot moves the axis the ring turns around. In the stone section it moves up to the diamond, so the close-up stays centred on it while it spins. In the exploded view the stone lifts out and tilts, the claws rotate open around their base, and labels with thin leader lines point to the parts. Their anchors are 3D points projected every frame and stacked into a column so they never overlap. Drag the ring and it keeps turning with inertia, then settles on the nearest full turn.</p>
<h2>Performance</h2>
<p>Phones (under 760 px wide or with a coarse pointer) get a pixel ratio of 1.5 instead of 2, 4 bounces instead of 7, a 256 px cube map and about half the segments on the band and claws. The expensive shader runs only on pixels the stone covers, but each of them pays 89 plane tests and up to four cube lookups per bounce, which is why the bounce count is the first thing phones lose. <code>renderer.compile()</code> runs before the loader fades, fonts load before the engraving canvas is drawn, and old geometry is disposed of when the cut changes. With <code>prefers-reduced-motion</code>, smooth scrolling is off and idle motion drops to 15 %.</p>
<p>Demo: <a href="https://smirnov-artur.github.io/demo/gad-ring/">https://smirnov-artur.github.io/demo/gad-ring/</a> · More work: <a href="https://smirnov-artur.github.io/works/en">https://smirnov-artur.github.io/works/en</a> · I build sites like this for brands — from $290.</p>
]]></content:encoded></item><item><title><![CDATA[A detailing-studio website where scrolling polishes the car: how it works in three.js]]></title><description><![CDATA[LUSTRE is a concept site I made for a car detailing studio. The brand is made up and there is no client behind it; it's a demo of one idea.
Detailing is hard to sell with photos. In a phone-sized pict]]></description><link>https://gadwebsites.hashnode.dev/a-detailing-studio-website-where-scrolling-polishes-the-car-how-it-works-in-three-js</link><guid isPermaLink="true">https://gadwebsites.hashnode.dev/a-detailing-studio-website-where-scrolling-polishes-the-car-how-it-works-in-three-js</guid><category><![CDATA[ThreeJS]]></category><category><![CDATA[WebGL]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Artur Smirnov (g.a.d.)]]></dc:creator><pubDate>Wed, 30 Sep 2026 06:40:50 GMT</pubDate><content:encoded><![CDATA[<img src="https://cdn.hashnode.com/uploads/covers/6abcabe79eb621c713380b33/0f37bcf3-c551-4363-ac10-1199f9aad8a3.jpg" alt="LUSTRE hero: a black car under a hexagonal LED ceiling" style="display:block;margin:0 auto" />

<p>LUSTRE is a concept site I made for a car detailing studio. The brand is made up and there is no client behind it; it's a demo of one idea.</p>
<p>Detailing is hard to sell with photos. In a phone-sized picture, swirl marks and haze barely show, and that difference is the whole service. So on this page the visitor does the work: they scroll, and the car goes from hazed to polished, then gets a ceramic coat, then protection film, then a new colour. A light bar follows the cursor, the same way a detailer walks an inspection lamp along a panel to find swirls.</p>
<p>The stack is small: three.js 0.186 and Lenis from a CDN, no build step. One <code>index.html</code>, one <code>app.js</code> of 467 lines, and a 1.6 MB car model.</p>
<p>Live site: <a href="https://smirnov-artur.github.io/demo/lustre-detailing/">https://smirnov-artur.github.io/demo/lustre-detailing/</a></p>
<h2>The car and the studio</h2>
<p>The car is <code>ferrari.glb</code> from the three.js examples ("Ferrari 458 Italia" by vicent091036, CC BY 4.0), Draco-compressed. All the materials are replaced after loading: the loader walks the scene, gives the <code>body</code> mesh my own paint material, turns rims and trim into dark metal and darkens the interior. The brand on the site is fictional, so the badges are cut out right in the chrome shader with a <code>discard</code> inside two small world-space boxes.</p>
<p>The studio around it is a hexagonal LED ceiling, like the ones in real detailing bays. It's 53 cells drawn as one <code>InstancedMesh</code>, so the whole ceiling is a single draw call. Each cell has an <code>aOn</code> attribute, and on load the cells switch on from the centre outwards with a short flicker, like fluorescent tubes warming up.</p>
<p>Reflections come from a second, invisible scene: the same hex grid, two side softboxes, a dark floor and a cylinder wall with a gradient. The bright band in that gradient sits at body height, and it's what draws the long highlight along the sides of the car. That scene is baked once through <code>PMREMGenerator</code> and used as the environment map: 512 px on desktop, 256 on phones.</p>
<h2>Scroll drives the camera</h2>
<p>There are four service sections (correction, ceramic, film, colour), and each has a camera pose: azimuth, elevation, distance, look-at height and a screen offset that pushes the car left or right to make room for the text card. Keyframes sit at the centre of each section:</p>
<pre><code class="language-js">const P = POSE[tall ? 'tall' : 'wide'];
let i = 0; while (i &lt; K.length - 2 &amp;&amp; ys &gt; K[i + 1]) i++;
const f = sstep(0.2, 0.8, (ys - K[i]) / Math.max(1, K[i + 1] - K[i]));
const a = P[i], b = P[i + 1];
cam.az = mix(a[0], b[0], f); cam.el = mix(a[1], b[1], f); cam.d = mix(a[2], b[2], f);
</code></pre>
<p>The <code>sstep(0.2, 0.8, …)</code> is the part that matters. The camera stands still for the first and last 20% of each stretch and only moves in the middle, so it isn't drifting while someone reads a price.</p>
<p>Lenis runs with <code>autoRaf: false</code> and is ticked from the same <code>requestAnimationFrame</code> as the renderer, and the scroll value gets one more exponential smoothing on top (<code>ys += (lenis.animatedScroll - ys) * (1 - Math.exp(-dt * 7))</code>). Portrait screens get their own set of poses: the car stays centred and higher in the frame, and the text card sits at the bottom.</p>
<p>Elevation has a guard, <code>Math.asin((CEIL - 0.8 - cam.ty) / d)</code>, so on the steep poses and during the intro zoom-out the camera never rises through the light ceiling.</p>
<h2>The inspection light</h2>
<p>The light bar is a <code>RectAreaLight</code>, 5 by 0.3 units. It's placed on the camera's side of the car and slides with the cursor:</p>
<pre><code class="language-js">const auto = t - lastPtr &gt; 2500;
const tx = auto ? Math.sin(T * 0.55) * 0.85 : px, ty = auto ? -0.25 + Math.sin(T * 0.37) * 0.25 : py;
mx += (tx - mx) * (1 - Math.exp(-dt * 3)); my += (ty - my) * (1 - Math.exp(-dt * 3));
</code></pre>
<p>If the mouse hasn't moved for 2.5 seconds, the bar sweeps along the body by itself. On phones nobody hovers, so that sweep is mostly what people see there.</p>
<h2>Polishing with one shader</h2>
<p>This is the main effect. The body uses a <code>MeshPhysicalMaterial</code> with clearcoat, patched through <code>onBeforeCompile</code>. The shader always holds two paint states, A and B, and a wipe position along the car's length (world Z). On one side of the line the shader draws A, on the other B, and scrolling moves the line from one end of the car to the other:</p>
<pre><code class="language-glsl">float nw = 1.0 - smoothstep(uWipe - 0.012, uWipe + 0.012, vWP.z);
vec4 pm = mix(uMatA, uMatB, nw); vec2 px = mix(uXA, uXB, nw);
float dirt = px.x * smoothstep(0.3, 0.8, n3(vWP * 2.6) * 0.6 + n3(vWP * 9.0) * 0.4);
diffuseColor.rgb = mix(mix(uColA, uColB, nw), vec3(0.16, 0.16, 0.165), dirt * 0.55);
</code></pre>
<p>Each state is a small set of numbers: colour, roughness, metalness, clearcoat, clearcoat roughness, dirt and iridescence. "As it arrived" has roughness 0.6, clearcoat 0.5 and full dirt. "Gloss" is roughness 0.2 with clearcoat 1. "Ceramic 9H" goes down to 0.14 and adds some iridescence. "Stealth film" is a satin 0.5. The dirt is two octaves of 3D value noise in world space: it greys the colour, adds roughness and eats the clearcoat, so the haze sits in patches instead of as a flat filter.</p>
<p>The line itself glows. The emissive term is added right at the wipe position:</p>
<pre><code class="language-glsl">float gd = abs(vWP.z - uWipe);
totalEmissiveRadiance += uGlowCol * uGlow * (exp(-gd * 40.0) * 1.6 + exp(-gd * 5.0) * 0.12);
</code></pre>
<p>The glow colour tells you which service is running: warm orange for polishing, cyan for ceramic, white for film. Its strength is <code>sin(π·w)^0.4</code>, so it fades in and out at both ends of the pass and doesn't pop.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6abcabe79eb621c713380b33/2c85be58-698d-491b-9788-f45f7ec34730.jpg" alt="Mid-pass: the front half is polished, the rear is still hazed, a warm line between them" style="display:block;margin:0 auto" />

<p>Before the first pass there's one step without a line: the car gets hazed as a whole, so the visitor sees what "as it arrived" means before the polishing starts.</p>
<p>The colour buttons reuse the same wipe. A click remembers which paint is on the car right now and runs A → B over 1200 ms with a smoothstep curve. The new colour sweeps along the car behind the same glowing line as the polish. There are five colours: liquid chrome, racing green, papaya, Nardo grey and pearl white.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6abcabe79eb621c713380b33/bbd6c327-1787-4cb0-b60f-70575e4afaae.jpg" alt="Colour change: papaya wrap and the colour buttons" style="display:block;margin:0 auto" />

<h2>Phones and weak GPUs</h2>
<p>The page checks <code>matchMedia('(pointer: coarse)')</code> or a width under 760 px, and phones get a lighter scene:</p>
<ul>
<li><p>pixel ratio capped at 1.5 instead of 2;</p>
</li>
<li><p>iridescence off;</p>
</li>
<li><p>environment map at 256 px instead of 512;</p>
</li>
<li><p>150 dust particles instead of 380;</p>
</li>
<li><p>no floor reflection. On desktop the reflection is a cheap trick: a clone of the car flipped with <code>scale.y = -1</code> under a floor that's 80% opaque. Phones get a solid floor and no second car.</p>
</li>
</ul>
<p>A <code>?hq</code> parameter forces full quality on a phone, which I use when recording video.</p>
<p>On top of that there's a runtime check for any device:</p>
<pre><code class="language-js">if (t0 !== null &amp;&amp; ti &gt; 1 &amp;&amp; ti &lt; 6 &amp;&amp; slow &lt; 2) {
  frames = dt &gt; 1 / 42 ? frames + 1 : Math.max(0, frames - 1);
  if (frames &gt; 45) { slow++; frames = 0; dpr = Math.max(TOUCH ? 0.85 : 1, dpr * 0.75); renderer.setPixelRatio(dpr); layout(); }
}
</code></pre>
<p>It runs between the first and the sixth second after the intro starts. A frame slower than 1/42 s adds a point, a fast frame takes one away. At 45 points the page renders 25% fewer pixels per side, at most twice. It never goes lower than 0.85 on phones or 1 on desktop.</p>
<p>A few smaller things keep frames even. <code>renderer.compile(scene, camera)</code> runs before the loader fades out, so shaders don't compile on the first scroll. The HUD text and the section rail only touch the DOM when their value actually changes. <code>prefers-reduced-motion</code> removes the slow camera drift and stops the film grain.</p>
<h2>Things that went wrong on the way</h2>
<p>A few things broke along the way, and the fixes are still visible in the code.</p>
<p>On Windows, Chrome's D3D backend flooded the console with warnings about three.js shader constants. Shader error checking is now on only with <code>?debug</code>.</p>
<p>The big LUSTRE word behind the car is a canvas texture, and a canvas draws with whatever font is loaded at that moment. So the texture is built right away and again when <code>document.fonts.load</code> reports that Archivo is ready. On tall screens it's rebuilt as two lines, LUS and TRE.</p>
<p>On portrait phones the text cards covered the car while the next section came in. Now a card fades out as its bottom edge leaves the lower part of the screen.</p>
<h2>If the phone is too weak even for that</h2>
<p>The fallback I'd use is a scroll-scrubbed video of the same camera path. It's cheap to produce here because all animation time comes only from <code>requestAnimationFrame</code>: the page plays the same way in a browser and in a frame-by-frame recording. That's how it can be recorded for video, like this short: <a href="https://youtube.com/shorts/-pgQFCKBG0c">https://youtube.com/shorts/-pgQFCKBG0c</a></p>
<p>Live site: <a href="https://smirnov-artur.github.io/demo/lustre-detailing/">https://smirnov-artur.github.io/demo/lustre-detailing/</a> · Portfolio: <a href="https://smirnov-artur.github.io/works/en">https://smirnov-artur.github.io/works/en</a> · I build 3D sites like this one for businesses, prices here: <a href="https://smirnov-artur.github.io/works/pricing/">https://smirnov-artur.github.io/works/pricing/</a></p>
]]></content:encoded></item></channel></rss>