Recently, while working on our game Wind Rider for Meta Quest devices in Unity, we've started using a workflow often referred to as "vibe coding." If you haven't heard this term yet, vibe coding is basically programming heavily assisted by Large Language Models (LLMs) like ChatGPT or, in our case, GitHub Copilot integrated into VS Code. This AI-driven method has noticeably sped up how quickly we can produce content, especially when it comes to editor tooling.
Before AI tools became as effective as they are now, making editor tooling involved a lot of iterations, careful coding, and at least some specialized knowledge. To be frank, it was often pretty tedious and time-consuming. Things have changed a lot recently, but that doesn't mean we're ditching coding altogether or that AI will completely replace our jobs. Instead, AI is helping us work smarter, allowing us to spend more time on the parts of development that really matter, making games that people genuinely enjoy and support.
There's a common misunderstanding that vibe coding is just giving a command and watching the computer magically handle everything. It's not quite that simple. Effective prompting and integration of AI into your workflow require a good balance of technical skill and creativity. You need to know your terminology, clearly communicate your intent, and structure your projects in a way that AI can meaningfully assist you.
In our experience, tooling is a great place to start with AI. Unlike the main game code, tooling can afford some imperfections or "jank," since its main job is just to speed things up internally. Using AI for tooling gives you significant productivity benefits without risking instability in your primary systems.
To clearly demonstrate this, we've put together a video that walks through exactly how AI-assisted vibe coding can streamline prefab placement in Unity. Specifically, we tackled the challenge of placing grass efficiently on terrains, an annoyingly repetitive task at base.
Here's the process we followed:
- Identify the issue:
Manually placing grass on uneven terrain and aligning each piece properly is tedious and inefficient. - Recognize a basic solution:
Aligning grass to the terrain's surface normals (the vectors that always point directly out from the surface) easily solves the alignment issue. - Write a simple script outline:
Sketch out the basic functionality needed, possibly with comments or guidance for your next steps. - Ask GitHub Copilot to create an Editor Window:
Provide simple, clear prompts for Copilot to generate a script that aligns prefabs to terrain normals. - Iterate on the script:
Prompt Copilot to enhance the script by enabling it to spawn multiple prefabs within a specified radius. - Add random rotation:
Either manually or via AI, write a simple script to randomly rotate prefabs along their axes. - Integrate rotation into the Editor Window:
Use Copilot to smoothly incorporate your rotation logic into the prefab placement tool. - Review and identify what's missing:
Pure randomness doesn't look natural, highlighting a need for structured randomness. Use Perlin Noise for better placement:
Have Copilot add a customizable Perlin Noise pattern to make grass placement look more organic and realistic.By following these steps, we quickly developed a practical Unity editor tool. As you'll see in the video, this method demonstrates how AI-powered vibe coding can genuinely boost productivity without complicating your development process.
//heres the editor window, if you're interested in taking a look
using UnityEngine;
using UnityEditor;
public class PlacementHelper : EditorWindow
{
private static bool isAutoOrientEnabled = true;
private static LayerMask placementLayerMask = -1; // All layers by default
private static PlacementHelper instance;
private static GameObject cachedPreviewInstance = null;
private static GameObject cachedPrefabAsset = null;
private static Vector3 lastValidNormal = Vector3.up;
// Cache for random rotation during drag
private static Vector3 cachedRandomRotation = Vector3.zero;
private static bool hasRandomRotation = false;
private Vector2 additionalRandomXRotation = Vector2.zero;
private Vector2 additionalRandomYRotation = Vector2.zero;
private Vector2 additionalRandomZRotation = Vector2.zero;
// Multi-placement fields
private int multiPlacementCount = 1;
private float multiPlacementRadius = 1f;
private float multiPlacementNoiseScale = 1f;
[MenuItem("Tools/Placement Helper")]
public static void ShowWindow()
{
instance = GetWindow<PlacementHelper>("Placement Helper");
instance.Show();
}
[InitializeOnLoadMethod]
private static void Initialize()
{
SceneView.duringSceneGui += OnSceneGUI;
}
private void OnEnable()
{
instance = this;
}
private void OnDisable()
{
if (instance == this)
instance = null;
DestroyMultiPreviewInstances();
}
private void OnGUI()
{
EditorGUILayout.LabelField("Placement Helper", EditorStyles.boldLabel);
EditorGUILayout.Space();
isAutoOrientEnabled = EditorGUILayout.Toggle("Auto Orient to Surface", isAutoOrientEnabled);
string[] layerNames = new string[32];
for (int i = 0; i < 32; i++)
{
string layerName = LayerMask.LayerToName(i);
layerNames[i] = string.IsNullOrEmpty(layerName) ? "Layer " + i : layerName;
}
placementLayerMask = EditorGUILayout.MaskField("Placement Layers", placementLayerMask, layerNames);
EditorGUILayout.Space();
EditorGUILayout.HelpBox("Drag a prefab from the Project window into the Scene view. The object will auto-orient to the surface normal under the mouse.", MessageType.Info);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Random Rotation Ranges (Degrees)", EditorStyles.boldLabel);
additionalRandomXRotation = EditorGUILayout.Vector2Field("X Axis (Min, Max)", additionalRandomXRotation);
additionalRandomYRotation = EditorGUILayout.Vector2Field("Y Axis (Min, Max)", additionalRandomYRotation);
additionalRandomZRotation = EditorGUILayout.Vector2Field("Z Axis (Min, Max)", additionalRandomZRotation);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Multi-Placement", EditorStyles.boldLabel);
multiPlacementCount = EditorGUILayout.IntSlider("Count", multiPlacementCount, 1, 20);
multiPlacementRadius = EditorGUILayout.FloatField("Radius", multiPlacementRadius);
multiPlacementNoiseScale = EditorGUILayout.FloatField("Perlin Noise Scale", multiPlacementNoiseScale);
}
private static void OnSceneGUI(SceneView sceneView)
{
if (!isAutoOrientEnabled)
return;
Event e = Event.current;
// Only operate during drag and drop
if (DragAndDrop.objectReferences != null && DragAndDrop.objectReferences.Length > 0)
{
GameObject prefabAsset = DragAndDrop.objectReferences[0] as GameObject;
if (prefabAsset != null && PrefabUtility.IsPartOfPrefabAsset(prefabAsset))
{
// Cache the prefab asset
cachedPrefabAsset = prefabAsset;
// Find the preview instance Unity creates
FindAndCachePreviewInstance();
// Orient the preview instance
OrientPreviewInstance(sceneView);
// If this is a new drag or a new prefab, generate a new random rotation
if (cachedPrefabAsset != prefabAsset || !hasRandomRotation)
{
cachedRandomRotation = new Vector3(
Random.Range(instance.additionalRandomXRotation.x, instance.additionalRandomXRotation.y),
Random.Range(instance.additionalRandomYRotation.x, instance.additionalRandomYRotation.y),
Random.Range(instance.additionalRandomZRotation.x, instance.additionalRandomZRotation.y)
);
hasRandomRotation = true;
}
// Handle drop event for placement
if (Event.current.type == EventType.DragPerform)
{
HandlePlacementOnDrop();
Event.current.Use();
}
}
}
else
{
cachedPreviewInstance = null;
cachedPrefabAsset = null;
hasRandomRotation = false;
cachedRandomRotation = Vector3.zero;
DestroyMultiPreviewInstances();
multiPreviewSpawned = false;
}
}
private static void FindAndCachePreviewInstance()
{
if (cachedPrefabAsset == null)
{
cachedPreviewInstance = null;
return;
}
// Find all hidden objects in the scene
GameObject[] allObjects = GameObject.FindObjectsByType<GameObject>(FindObjectsSortMode.None);
foreach (GameObject obj in allObjects)
{
if (obj.hideFlags != HideFlags.None &&
PrefabUtility.GetCorrespondingObjectFromSource(obj) == cachedPrefabAsset)
{
cachedPreviewInstance = obj;
return;
}
}
cachedPreviewInstance = null;
}
// Multi-placement preview cache
private static GameObject[] multiPreviewInstances = null;
private static Vector3[] multiCachedRandomRotations = null;
// Track if we've already spawned multi-placement previews for this drag
private static bool multiPreviewSpawned = false;
private static Vector3[] multiPreviewOffsets = null;
private static void OrientPreviewInstance(SceneView sceneView)
{
if (cachedPreviewInstance == null)
return;
// Get mouse position in scene view
Vector2 mousePos = Event.current != null ? Event.current.mousePosition : sceneView.position.center;
Ray ray = HandleUtility.GUIPointToWorldRay(mousePos);
RaycastHit hit;
int count = instance != null ? instance.multiPlacementCount : 1;
float radius = instance != null ? instance.multiPlacementRadius : 1f;
float noiseScale = instance != null ? instance.multiPlacementNoiseScale : 1f;
// Only spawn multi-placement previews once per drag
if (count > 1 && !multiPreviewSpawned)
{
DestroyMultiPreviewInstances();
multiPreviewInstances = new GameObject[count];
multiCachedRandomRotations = new Vector3[count];
multiPreviewOffsets = new Vector3[count];
for (int i = 0; i < count; i++)
{
multiPreviewInstances[i] = Object.Instantiate(cachedPreviewInstance);
multiPreviewInstances[i].hideFlags = HideFlags.HideAndDontSave;
multiCachedRandomRotations[i] = new Vector3(
Random.Range(instance.additionalRandomXRotation.x, instance.additionalRandomXRotation.y),
Random.Range(instance.additionalRandomYRotation.x, instance.additionalRandomYRotation.y),
Random.Range(instance.additionalRandomZRotation.x, instance.additionalRandomZRotation.y)
);
// Sample a random point in a disk using Perlin noise
float t = (float)i / count;
float angle = Random.Range(0f, 2f * Mathf.PI);
float perlin = Mathf.PerlinNoise(Mathf.Cos(angle) * noiseScale + 100 + t * 10, Mathf.Sin(angle) * noiseScale + 100 + t * 10);
float r = radius * Mathf.Sqrt(perlin);
float x = Mathf.Cos(angle) * r;
float z = Mathf.Sin(angle) * r;
multiPreviewOffsets[i] = new Vector3(x, 0, z);
}
multiPreviewSpawned = true;
}
else if (count <= 1)
{
DestroyMultiPreviewInstances();
multiPreviewSpawned = false;
}
if (Physics.Raycast(ray, out hit, Mathf.Infinity, placementLayerMask))
{
lastValidNormal = hit.normal;
cachedPreviewInstance.transform.position = hit.point;
OrientObjectToNormal(cachedPreviewInstance, hit.normal);
// Multi-placement update
if (count > 1 && multiPreviewInstances != null)
{
Camera cam = sceneView.camera;
// Build a local tangent basis for the hit normal
Vector3 tangent = Vector3.Cross(hit.normal, Vector3.up);
if (tangent.sqrMagnitude < 0.001f) tangent = Vector3.Cross(hit.normal, Vector3.right);
tangent.Normalize();
Vector3 bitangent = Vector3.Cross(hit.normal, tangent);
for (int i = 0; i < count; i++)
{
// Offset in the tangent plane
Vector3 localOffset = tangent * multiPreviewOffsets[i].x + bitangent * multiPreviewOffsets[i].z;
Vector3 offsetWorldPos = hit.point + localOffset;
// Raycast from camera through this offset point
Ray camRay = cam != null ? cam.ScreenPointToRay(cam.WorldToScreenPoint(offsetWorldPos)) : new Ray(offsetWorldPos + hit.normal * 2f, -hit.normal);
RaycastHit offsetHit;
if (Physics.Raycast(camRay, out offsetHit, Mathf.Infinity, placementLayerMask))
{
multiPreviewInstances[i].transform.position = offsetHit.point;
OrientObjectToNormal(multiPreviewInstances[i], offsetHit.normal, multiCachedRandomRotations[i]);
}
else
{
multiPreviewInstances[i].transform.position = offsetWorldPos;
OrientObjectToNormal(multiPreviewInstances[i], hit.normal, multiCachedRandomRotations[i]);
}
}
}
}
else
{
OrientObjectToNormal(cachedPreviewInstance, lastValidNormal);
DestroyMultiPreviewInstances();
multiPreviewSpawned = false;
}
sceneView.Repaint();
}
private static void DestroyMultiPreviewInstances()
{
if (multiPreviewInstances != null)
{
foreach (var go in multiPreviewInstances)
{
if (go != null) Object.DestroyImmediate(go);
}
multiPreviewInstances = null;
multiCachedRandomRotations = null;
multiPreviewOffsets = null;
}
}
// Handle actual placement on drop
private static void HandlePlacementOnDrop()
{
if (multiPreviewInstances != null && multiPreviewInstances.Length > 1 && cachedPrefabAsset != null)
{
Undo.IncrementCurrentGroup();
for (int i = 0; i < multiPreviewInstances.Length; i++)
{
GameObject placed = (GameObject)PrefabUtility.InstantiatePrefab(cachedPrefabAsset);
placed.transform.position = multiPreviewInstances[i].transform.position;
placed.transform.rotation = multiPreviewInstances[i].transform.rotation;
Undo.RegisterCreatedObjectUndo(placed, "Place Prefab");
}
Undo.CollapseUndoOperations(Undo.GetCurrentGroup());
}
else if (cachedPreviewInstance != null && cachedPrefabAsset != null)
{
// Single placement
GameObject placed = (GameObject)PrefabUtility.InstantiatePrefab(cachedPrefabAsset);
placed.transform.position = cachedPreviewInstance.transform.position;
placed.transform.rotation = cachedPreviewInstance.transform.rotation;
Undo.RegisterCreatedObjectUndo(placed, "Place Prefab");
}
DestroyMultiPreviewInstances();
cachedPreviewInstance = null;
cachedPrefabAsset = null;
hasRandomRotation = false;
cachedRandomRotation = Vector3.zero;
multiPreviewSpawned = false;
}
private static void OrientObjectToNormal(GameObject obj, Vector3 normal, Vector3? randomRot = null)
{
Vector3 forward = Vector3.Cross(normal, Vector3.right);
if (forward.magnitude < 0.1f)
forward = Vector3.Cross(normal, Vector3.forward);
forward.Normalize();
obj.transform.rotation = Quaternion.LookRotation(forward, normal);
if (randomRot.HasValue)
{
obj.transform.Rotate(randomRot.Value, Space.Self);
}
else if (hasRandomRotation)
{
obj.transform.Rotate(cachedRandomRotation, Space.Self);
}
}
}