Building This Nature Does Not Exist
thisnaturedoesnotexist.com shows you a landscape that was never photographed. Every image on it was made by a diffusion model I wrote myself, from an empty file, in PyTorch - no pretrained weights and no hosted API anywhere in the stack.
















This post is the short version of how it got built: a laptop, a rented GPU, a crash, a few bugs, and the decisions that made each step cheaper or faster. If you want the math and the architecture, that is in the companion post, Building a Diffusion Model From Scratch.
Why nature that doesn't exist
The idea borrows from thispersondoesnotexist.com: one page, one job, a fresh generated image every time you look. I picked landscapes instead of faces.
The real goal was to understand image generation all the way down. A lot of "AI image" projects wrap someone else's model. I wanted to write every layer myself, train it on hardware I paid for by the hour, and ship it - then write up what happened honestly, including the parts that went wrong.
A spiral made of noise
Before touching a single image, I built a toy. The whole dataset was a scatter of 2D points in a spiral shape, and the network was a tiny multi-layer perceptron. The model learned to start from random dots and walk them back into the spiral.
That sounds trivial, and it was meant to be. With no images and no complex network, there was nowhere for a bug to hide. Once the toy worked, I knew the core training and sampling loop was right, and I could scale it up to pixels with confidence.

Training on a laptop
The first real models trained on my M1 laptop: about 6,000 landscape photos from the monet2photo dataset, first at 32 pixels, then at 64. The results were soft coloured blobs that were starting to look like horizons. Good enough to prove the pipeline end to end.


The 32-to-64 handoff caught a bug that would have cost real money later. When I loaded the 32px weights and asked for "60 epochs", my --epochs flag was being read as an absolute epoch number, not "run this many more". A run I believed was 60 epochs would have silently trained only 20. I caught it before it wasted a run and fixed it.
The laptop also had a blind spot. Mixed precision (AMP), which roughly halves memory and speeds up training on NVIDIA GPUs, could not be tested on the M1 at all. I wrote the code anyway. The first confirmation that it worked was a single line printed on the rented cloud machine: device: cuda amp: True.
Renting a GPU for about $8
The real run trained a bigger model at 128 pixels on about 90,000 landscapes from the LHQ dataset. That needed a real GPU, so I rented one on RunPod. Before picking, I worked out what each option would actually cost.
I measured one real number - the RTX 4090 took 433 seconds per epoch - and estimated the rest from it, so they are ballpark:
The big data-center cards were money on fire for this job: a 48M-parameter model cannot fill their memory or keep their cores busy. At 433 seconds per epoch, the 4090 needed about 12 hours for 100 epochs.
The run did not go cleanly. At epoch 32 it crashed: one worker failed to read one image off the network storage volume (OSError: [Errno 6]). That was not a code or data bug - all 90,000 files had been read cleanly 31 times already. It was a momentary storage hiccup. I made the data loader retry a failed read and fall back to a neighbouring image, then resumed from the epoch-31 checkpoint. Because I saved a checkpoint every epoch, the crash cost about seven minutes instead of the whole run.
The jump in quality from the laptop to the cloud was huge. It is tempting to credit the higher resolution, but most of it came from the data: 15× more images, and cleaner ones.
Shipping it
My first plan was to run the model live in the visitor's browser, with no server GPU at all. I exported it to ONNX (about 95-190MB) and ran it through ONNX Runtime Web on WebGPU. It worked: 18.00 seconds per image on average on a laptop.
That number decided it. Eighteen seconds is too long to be the default "click and see" experience, and phones and browsers without WebGPU would be slower or unable to run it at all. thispersondoesnotexist.com does not run its model live either - it serves pre-generated images. I first planned a hybrid with live generation as an opt-in, then in October dropped live generation entirely. Today every image on the site is pre-generated by the model and ships with the static site, so it loads instantly.
What I would do differently
Fill the GPU I already paid for. I chose the 4090 carefully, then set the batch size to 32, which barely touched its 24GB of memory. At a batch of 96-128 the same card would have finished faster for the same hourly price. The biggest lever was never the GPU.
Trust the samples, not the loss. The training loss fell from about 0.043 to 0.026 in a few epochs and then barely moved, while the images kept getting much better. On a generative model, the sample grid is the real progress signal.




Do not trust the first read of a result. When training finished, the sample images had speckles and solid flat-colour tiles. I assumed the model was undertrained: 100 epochs is about 281,000 training steps, and the original DDPM paper used 800,000+ for images a quarter the size. I was ready to rent more GPU time.



Then I tested the full, slow 1000-step sampler as a reference, and it came back worse - tiles of pure red, white, black and cyan, the corners of colour space where numbers blow up. That code path had never run on a real trained model. It lacked a safety clamp the fast sampler already had, so small errors compounded over 1000 steps. I fixed it, re-ran the same seed, and got real landscapes: a coastal cliff with reflections, a glacier lake, sunsets. The model was fine. The sampler was the dominant problem.


It happened twice. The fast sampler still showed some "confetti" artifacts, so I tested more steps: 50, 75 and 100 changed almost nothing. Adding a little randomness back into each step fixed it entirely, at 3.6 seconds per image instead of about 29 for the slow sampler. Both quality problems on this project turned out to be the sampler, not the model - and both were found by testing, not by assuming a component worked because it looked right on paper.


Where it ended up
A model I wrote from scratch, trained for about $8 of rented GPU time, serving convincing landscapes on thisnaturedoesnotexist.com. The most useful part was not the result. It was the habits the bugs taught: checkpoint every epoch, make long jobs survive a bad read, measure cost as time × rate, and test every code path on real output before you trust it.
For the full technical version - the U-Net, the noise schedule, the sampler math, and the code for each fix - read Building a Diffusion Model From Scratch.