Building a Photo Studio¶
Welcome back! 
In this tutorial we'll turn the viewer into a mini photo studio — pick a lighting preset, fine-tune exposure and shadows, frame the shot, and download a high-res product image. A real workflow for anyone creating marketing visuals or catalogue shots.
This tutorial builds off the Basic implementation tutorial.
Let's get shooting!
1. Set Up the Page¶
We need a viewer and a panel of controls next to it. Let's set up the markup and some basic styling:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Photo Studio</title>
<style>
body {
font-family: sans-serif;
}
main {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
#viewer-container {
width: 500px;
height: 500px;
}
.studio-controls {
display: flex;
flex-direction: column;
gap: .75rem;
max-width: 500px;
}
.studio-controls label {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.studio-controls input[type="range"] {
width: 200px;
}
#capture {
align-self: flex-start;
padding: .5rem 1.5rem;
font-size: 1rem;
cursor: pointer;
}
</style>
</head>
<body>
<h1>Photo Studio</h1>
<main>
<div id="viewer-container">
<div class="js-270viewer"
data-270-model="generic-shoe"
data-270-background="#eeeeee"
data-270-shadow="true">
</div>
</div>
<div class="studio-controls">
<label>Lighting
<select id="lighting">
<option value="neutral" selected>Neutral</option>
<option value="neutral-2">Neutral 2</option>
<option value="studio">Studio</option>
<option value="sunset">Sunset</option>
<option value="sunrise">Sunrise</option>
<option value="night">Night</option>
<option value="forest">Forest</option>
<option value="courtyard">Courtyard</option>
<option value="city">City</option>
<option value="interior">Interior</option>
</select>
</label>
<label>Exposure
<input type="range" id="exposure" min="0" max="4" step="0.1" value="1" />
</label>
<label>Light Rotation
<input type="range" id="rotation" min="0" max="360" step="1" value="0" />
</label>
<label>Shadow Opacity
<input type="range" id="shadow-opacity" min="0" max="1" step="0.05" value="0.4" />
</label>
<label>Shadow Blur
<input type="range" id="shadow-blur" min="0" max="20" step="0.5" value="6" />
</label>
<label>Camera Angle
<select id="camera-angle">
<option value="auto" selected>Auto</option>
<option value="front">Front</option>
<option value="back">Back</option>
<option value="left">Left</option>
<option value="right">Right</option>
</select>
</label>
<button id="capture">📸 Capture</button>
</div>
</main>
<script>window.TSDAPIKEY = 'rest-of-the-owl';</script>
<script src="https://api.270degrees.nl/api/script/latest/viewer.js"></script>
<script>
// We'll fill this in step by step
</script>
</body>
</html>
We've got a good-looking control panel ready to go. Now let's make it do something.
Important: Wait for the Model to Load¶
Before we wire up any controls, we need to make sure the 3D model is fully loaded — otherwise the methods we call won't do anything yet. The viewer provides an onLoadComplete callback for exactly this. Let's set up our script using TSDViewer.create() and put all our logic inside that callback:
const $viewer = document.querySelector('.js-270viewer');
TSDViewer.create($viewer, {
onLoadComplete: () => {
// The model is ready — all methods are now available.
// We'll add our controls here.
}
});
Everything from here on goes inside that onLoadComplete callback. This guarantees the viewer is fully initialised before we start calling methods on it.
2. Lighting Presets¶
The API ships with a bunch of built-in lighting environments. Let's hook up the dropdown to setLightingType:
document.querySelector('#lighting').addEventListener('change', (ev) => {
$viewer.setLightingType(ev.target.value);
});
Pick Sunset or Night from the dropdown and watch the whole mood of the scene change instantly. Each preset is a full HDRI environment, so reflections and colours shift too.
3. Exposure & Light Rotation¶
Presets are a great starting point, but sometimes you need to dial things in. Let's add fine control with setLightingExposure and setLightingRotation:
document.querySelector('#exposure').addEventListener('input', (ev) => {
$viewer.setLightingExposure(parseFloat(ev.target.value));
});
document.querySelector('#rotation').addEventListener('input', (ev) => {
$viewer.setLightingRotation(parseFloat(ev.target.value));
});
- Exposure controls overall brightness — crank it up for a bright, airy feel or pull it down for drama.
- Light Rotation spins the environment around the model, changing where highlights and shadows land.
4. Shadows¶
Shadows sell the realism. Let's give the user control over setShadowOpacity and setShadowBlur:
document.querySelector('#shadow-opacity').addEventListener('input', (ev) => {
$viewer.setShadowOpacity(parseFloat(ev.target.value));
});
document.querySelector('#shadow-blur').addEventListener('input', (ev) => {
$viewer.setShadowBlur(parseFloat(ev.target.value));
});
A sharp shadow with high opacity feels like harsh studio light. Soft blur with low opacity is more like a cloudy day. Play around!
5. Camera Angles¶
Let's jump between preset angles using setCameraPosition. It accepts named presets like front, top, left, etc.:
document.querySelector('#camera-angle').addEventListener('change', (ev) => {
$viewer.setCameraPosition({preset: ev.target.value});
});
Quick framing without fiddling with phi and theta values. Perfect for catalogue-style shots from consistent angles.
You can also pass {zoom, phi, theta} for full manual control — see the Interactivity tutorial for an example.
6. Capture the Shot¶
The whole point of a photo studio: take the picture. We'll use getImage with a high resolution to download a proper product shot:
document.querySelector('#capture').addEventListener('click', () => {
$viewer.getImage({
download: true,
width: 1080,
height: 1080
});
});
Click Capture and a 1080×1080 image downloads straight to your machine. The image uses whatever lighting, shadow, and camera settings you've dialled in.
You can also get the image as a base64 string instead of a download — handy for uploading directly to a server or showing a preview.
7. End Result¶
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Photo Studio</title>
<style>
body {
font-family: sans-serif;
}
#viewer-container {
width: 500px;
height: 500px;
}
.studio-controls {
display: flex;
flex-direction: column;
gap: .75rem;
max-width: 500px;
margin-top: 1rem;
}
.studio-controls label {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.studio-controls input[type="range"] {
width: 200px;
}
#capture {
align-self: flex-start;
padding: .5rem 1.5rem;
font-size: 1rem;
cursor: pointer;
}
</style>
</head>
<body>
<main>
<h1>Photo Studio</h1>
<div id="viewer-container">
<div class="js-270viewer"
data-270-model="generic-shoe"
data-270-background="#eeeeee"
data-270-shadow="true">
</div>
</div>
<div class="studio-controls">
<label>Lighting
<select id="lighting">
<option value="neutral" selected>Neutral</option>
<option value="neutral-2">Neutral 2</option>
<option value="studio">Studio</option>
<option value="sunset">Sunset</option>
<option value="sunrise">Sunrise</option>
<option value="night">Night</option>
<option value="forest">Forest</option>
<option value="courtyard">Courtyard</option>
<option value="city">City</option>
<option value="interior">Interior</option>
</select>
</label>
<label>Exposure
<input type="range" id="exposure" min="0" max="4" step="0.1" value="1" />
</label>
<label>Light Rotation
<input type="range" id="rotation" min="0" max="360" step="1" value="0" />
</label>
<label>Shadow Opacity
<input type="range" id="shadow-opacity" min="0" max="1" step="0.05" value="0.4" />
</label>
<label>Shadow Blur
<input type="range" id="shadow-blur" min="0" max="20" step="0.5" value="6" />
</label>
<label>Camera Angle
<select id="camera-angle">
<option value="auto" selected>Auto</option>
<option value="front">Front</option>
<option value="back">Back</option>
<option value="left">Left</option>
<option value="right">Right</option>
</select>
</label>
<button id="capture">📸 Capture</button>
</div>
</main>
<script>window.TSDAPIKEY = 'rest-of-the-owl';</script>
<script src="https://api.270degrees.nl/api/script/latest/viewer.js"></script>
<script>
const $viewer = document.querySelector('.js-270viewer');
TSDViewer.create($viewer, {
onLoadComplete: () => {
console.log('Model loaded, ready to start interface');
document.querySelector('#lighting').addEventListener('change', (ev) => {
$viewer.setLightingType(ev.target.value);
});
document.querySelector('#exposure').addEventListener('input', (ev) => {
$viewer.setLightingExposure(parseFloat(ev.target.value));
});
document.querySelector('#rotation').addEventListener('input', (ev) => {
$viewer.setLightingRotation(parseFloat(ev.target.value));
});
document.querySelector('#shadow-opacity').addEventListener('input', (ev) => {
$viewer.setShadowOpacity(parseFloat(ev.target.value));
});
document.querySelector('#shadow-blur').addEventListener('input', (ev) => {
$viewer.setShadowBlur(parseFloat(ev.target.value));
});
document.querySelector('#camera-angle').addEventListener('change', (ev) => {
$viewer.setCameraPosition({ preset: ev.target.value });
});
document.querySelector('#capture').addEventListener('click', () => {
$viewer.getImage({
download: true,
size: {
width: 1080,
height: 1080
}
});
});
}
})
</script>
</body>
</html>
What's Next?¶
You've built a proper photo studio! Dial in the perfect look, then capture it in one click.
Keep building:
- Add product customisation with the Product Customiser tutorial
- Explore all lighting methods for even deeper control