How do I programmatically move an ARAnchor? - swift

I'm trying out the new ARKit to replace another similar solution I have. It's pretty great! But I can't seem to figure out how to move an ARAnchor programmatically. I want to slowly move the anchor to the left of the user.
Creating the anchor to be 2 meters in front of the user:
var translation = matrix_identity_float4x4
translation.columns.3.z = -2.0
let transform = simd_mul(currentFrame.camera.transform, translation)
let anchor = ARAnchor(transform: transform)
sceneView.session.add(anchor: anchor)
later, moving the object to the left/right of the user (x-axis)...
anchor.transform.columns.3.x = anchor.transform.columns.3.x + 0.1
repeated every 50 milliseconds (or whatever).
The above does not work because transform is a get-only property.
I need a way to change the position of an AR object in space relative to the user in a way that keeps the AR experience intact - meaning, if you move your device, the AR object will be moving but also won't be "stuck" to the camera like it's simply painted on, but moves like you would see a person move while you were walking by - they are moving and you are moving and it looks natural.
Please note the scope of this question relates only to how to move an object in space in relation to the user (ARAnchor), not in relation to a plane (ARPlaneAnchor) or to another detected surface (ARHitTestResult).
Thanks!

You don't need to move anchors. (hand wave) That's not the API you're looking for...
Adding ARAnchor objects to a session is effectively about "labeling" a point in real-world space so that you can refer to it later. The point (1,1,1) (for example) is always the point (1,1,1) β€” you can't move it someplace else because then it's not the point (1,1,1) anymore.
To make a 2D analogy: anchors are reference points sort of like the bounds of a view. The system (or another piece of your code) tells the view where it's boundaries are, and the view draws its content relative to those boundaries. Anchors in AR give you reference points you can use for drawing content in 3D.
What you're asking is really about moving (and animating the movement of) virtual content between two points. And ARKit itself really isn't about displaying or animating virtual content β€” there are plenty of great graphics engines out there, so ARKit doesn't need to reinvent that wheel. What ARKit does is provide a real-world frame of reference for you to display or animate content using an existing graphics technology like SceneKit or SpriteKit (or Unity or Unreal, or a custom engine built with Metal or GL).
Since you mentioned trying to do this with SpriteKit... beware, it gets messy. SpriteKit is a 2D engine, and while ARSKView provides some ways to shoehorn a third dimension in there, those ways have their limits.
ARSKView automatically updates the xScale, yScale, and zRotation of each sprite associated with an ARAnchor, providing the illusion of 3D perspective. But that applies only to nodes attached to anchors, and as noted above, anchors are static.
You can, however, add other nodes to your scene, and use those same properties to make those nodes match the ARSKView-managed nodes. Here's some code you can add/replace in the ARKit/SpriteKit Xcode template project to do that. We'll start with some basic logic to run a bouncing animation on the third tap (after using the first two taps to place anchors).
var anchors: [ARAnchor] = []
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// Start bouncing on touch after placing 2 anchors (don't allow more)
if anchors.count > 1 {
startBouncing(time: 1)
return
}
// Create anchor using the camera's current position
guard let sceneView = self.view as? ARSKView else { return }
if let currentFrame = sceneView.session.currentFrame {
// Create a transform with a translation of 30 cm in front of the camera
var translation = matrix_identity_float4x4
translation.columns.3.z = -0.3
let transform = simd_mul(currentFrame.camera.transform, translation)
// Add a new anchor to the session
let anchor = ARAnchor(transform: transform)
sceneView.session.add(anchor: anchor)
anchors.append(anchor)
}
}
Then, some SpriteKit fun for making that animation happen:
var ballNode: SKLabelNode = {
let labelNode = SKLabelNode(text: "πŸ€")
labelNode.horizontalAlignmentMode = .center
labelNode.verticalAlignmentMode = .center
return labelNode
}()
func startBouncing(time: TimeInterval) {
guard
let sceneView = self.view as? ARSKView,
let first = anchors.first, let start = sceneView.node(for: first),
let last = anchors.last, let end = sceneView.node(for: last)
else { return }
if ballNode.parent == nil {
addChild(ballNode)
}
ballNode.setScale(start.xScale)
ballNode.zRotation = start.zRotation
ballNode.position = start.position
let scale = SKAction.scale(to: end.xScale, duration: time)
let rotate = SKAction.rotate(toAngle: end.zRotation, duration: time)
let move = SKAction.move(to: end.position, duration: time)
let scaleBack = SKAction.scale(to: start.xScale, duration: time)
let rotateBack = SKAction.rotate(toAngle: start.zRotation, duration: time)
let moveBack = SKAction.move(to: start.position, duration: time)
let action = SKAction.repeatForever(.sequence([
.group([scale, rotate, move]),
.group([scaleBack, rotateBack, moveBack])
]))
ballNode.removeAllActions()
ballNode.run(action)
}
Here's a video so you can see this code in action. You'll notice that the illusion only works as long as you don't move the camera β€” not so great for AR. When using SKAction, we can't adjust the start/end states of the animation while animating, so the ball keeps bouncing back and forth between its original (screen-space) positions/rotations/scales.
You could do better by animating the ball directly, but it's a lot of work. You'd need to, on every frame (or every view(_:didUpdate:for:) delegate callback):
Save off the updated position, rotation, and scale values for the anchor-based nodes at each end of the animation. You'll need to do this twice per didUpdate callback, because you'll get one callback for each node.
Work out position, rotation, and scale values for the node being animated, by interpolating between the two endpoint values based on the current time.
Set the new attributes on the node. (Or maybe animate it to those attributes over a very short duration, so it doesn't jump too much in one frame?)
That's kind of a lot of work to shoehorn a fake 3D illusion into a 2D graphics toolkit β€” hence my comments about SpriteKit not being a great first step into ARKit.
If you want 3D positioning and animation for your AR overlays, it's a lot easier to use a 3D graphics toolkit. Here's a repeat of the previous example, but using SceneKit instead. Start with the ARKit/SceneKit Xcode template, take the spaceship out, and paste the same touchesBegan function from above into the ViewController. (Change the as ARSKView casts to as ARSCNView, too.)
Then, some quick code for placing 2D billboarded sprites, matching via SceneKit the behavior of the ARKit/SpriteKit template:
// in global scope
func makeBillboardNode(image: UIImage) -> SCNNode {
let plane = SCNPlane(width: 0.1, height: 0.1)
plane.firstMaterial!.diffuse.contents = image
let node = SCNNode(geometry: plane)
node.constraints = [SCNBillboardConstraint()]
return node
}
// inside ViewController
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
// emoji to image based on https://stackoverflow.com/a/41021662/957768
let billboard = makeBillboardNode(image: "⛹️".image())
node.addChildNode(billboard)
}
Finally, adding the animation for the bouncing ball:
let ballNode = makeBillboardNode(image: "πŸ€".image())
func startBouncing(time: TimeInterval) {
guard
let sceneView = self.view as? ARSCNView,
let first = anchors.first, let start = sceneView.node(for: first),
let last = anchors.last, let end = sceneView.node(for: last)
else { return }
if ballNode.parent == nil {
sceneView.scene.rootNode.addChildNode(ballNode)
}
let animation = CABasicAnimation(keyPath: #keyPath(SCNNode.transform))
animation.fromValue = start.transform
animation.toValue = end.transform
animation.duration = time
animation.autoreverses = true
animation.repeatCount = .infinity
ballNode.removeAllAnimations()
ballNode.addAnimation(animation, forKey: nil)
}
This time the animation code is a lot shorter than the SpriteKit version.
Here's how it looks in action.
Because we're working in 3D to start with, we're actually animating between two 3D positions β€” unlike in the SpriteKit version, the animation stays where it's supposed to. (And without the extra work for directly interpolating and animating attributes.)

Related

ARKit SCNNode always in the center when camera move

I am working on a project where I have to place a green dot to be always in the center even when we rotate the camera in ARKit. I am using ARSCNView and I have added the node so far everything is good. Now I know I need to modify the position of the node in
func session(_ session: ARSession, didUpdate frame: ARFrame)
But I have no idea how to do that. I saw some example which was close to what I have but it does not run as it suppose to.
func session(_ session: ARSession, didUpdate frame: ARFrame) {
let location = sceneView.center
let hitTest = sceneView.hitTest(location, types: .featurePoint)
if hitTest.isEmpty {
print("No Plane Detected")
return
} else {
let columns = hitTest.first?.worldTransform.columns.3
let position = SCNVector3(x: columns!.x, y: columns!.y, z: columns!.z)
var node = sceneView.scene.rootNode.childNode(withName: "CenterShip", recursively: false) ?? nil
if node == nil {
let scene = SCNScene(named: "art.scnassets/ship.scn")!
node = scene.rootNode.childNode(withName: "ship", recursively: false)
node?.opacity = 0.7
let columns = hitTest.first?.worldTransform.columns.3
node!.name = "CenterShip"
node!.position = SCNVector3(x: columns!.x, y: columns!.y, z: columns!.z)
sceneView.scene.rootNode.addChildNode(node!)
}
let position2 = node?.position
if position == position2! {
return
} else {
//action
let action = SCNAction.move(to: position, duration: 0.1)
node?.runAction(action)
}
}
}
It doesn't matter how I rotate the camera this dot must be in the middle.
It's not clear exactly what you're trying to do, but I assume its one of the following:
A) Place the green dot centered in front of the camera at a fixed distance, eg. always exactly 1 meter in front of the camera.
B) Place the green dot centered in front of the camera at the depth of the nearest detected plane, i.e. using the results of a raycast from the mid point of the ARSCNView
I would have assumed A, but your example code is using (now deprecated) sceneView.hitTest() function which in this case would give you the depth of whatever is behind the pixel at sceneView.center
Anyway here's both:
Fixed Depth Solution
This is pretty straightforward, though there are few options. The simplest is to make the green dot a child node of the scene's camera node, and give it position with a negative z value, since z increases as a position moves toward the camera.
cameraNode.addChildNode(textNode)
textNode.position = SCNVector3(x: 0, y: 0, z: -1)
As the camera moves, so too will its child nodes. More details in this very thorough answer
Scene Depth Solution
To determine the estimated depth behind a pixel, you should use ARSession.raycast instead of SceneView.hitTest, because the latter is definitely deprecated.
Note that, if the raycast() (or still hitTest()) methods return an empty result set (not uncommon given the complexity of scene estimation going on in ARKit), you won't have a position to update the node and this it might not be directly centered in every frame. To handle this is a bit more complex, as you'd need decide exactly what you want to do in that case.
The SCNAction is unnecessary and potentially causing problems. These delegate methods run 60fps, so simply updating the position directly will produce smooth results.
Adapting and simplifying the code you posted:
func createCenterShipNode() -> SCNNode {
let scene = SCNScene(named: "art.scnassets/ship.scn")!
let node = scene.rootNode.childNode(withName: "ship", recursively: false)
node!.opacity = 0.7
node!.name = "CenterShip"
sceneView.scene.rootNode.addChildNode(node!)
return node!
}
func session(_ session: ARSession, didUpdate frame: ARFrame) {
// Check the docs for what the different raycast query parameters mean, but these
// give you the depth of anything ARKit has detected
guard let query = sceneView.raycastQuery(from: sceneView.center, allowing: .estimatedPlane, alignment: .any) else {
return
}
let results = session.raycast(query)
if let hit = results.first {
let node = sceneView.scene.rootNode.childNode(withName: "CenterShip", recursively: false) ?? createCenterShipNode()
let pos = hit.worldTransform.columns.3
node.simdPosition = simd_float3(pos.x, pos.y, pos.z)
}
}
See also: ARRaycastQuery
One last note - you generally don't want to do scene manipulation within this delegate method. It runs on a different thread than the Scenekit rendering thread, and SceneKit is very thread sensitive. This will likely work fine, but beyond adding or moving a node will certainly cause crashes from time to time. You'd ideally want to store the new position, and then update the actual scene contents from within the renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) delegate method.

SCNNode facing towards the camera

I am trying to put SCNCylinder node in the scene on the touch point. I always want to show the cylinder shape diameter facing towards camera. Its working fine for horizontal scene but it have a problem in vertical scene. In vertical scene I can see the cylinder sides but I want to show the full diameter facing towards the camera no matter whats the camera orientation is. I know there is some transformation needs to be applied depending on the camera transform but don't know how. I am not using plane detection its the simple node which is directly added to the scene.
Vertical Image:
Horizontal Image:
The code to insert the node is as follows,
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else {
return
}
let result = sceneView.hitTest(touch.location(in: sceneView), types: [ARHitTestResult.ResultType.featurePoint])
guard let hitResult = result.last else {
print("returning because couldn't find the touch point")
return
}
let hitTransform = SCNMatrix4(hitResult.worldTransform)
let position = SCNVector3Make(hitTransform.m41, hitTransform.m42, hitTransform.m43)
let ballShape = SCNCylinder(radius: 0.02, height: 0.01)
let ballNode = SCNNode(geometry: ballShape)
ballNode.position = position
sceneView.scene.rootNode.addChildNode(ballNode)
}
Any help would be appreciated.
I'm not certain this is the right way to handle what you need but here is something which may help you.
I think CoreMotion could be useful to help you determine if the device is at a horizontal or vertical angle.
This class has a property called attitude, which describes the rotation of our device in terms of roll, pitch, and yaw. If we are holding our phone in portrait orientation, the roll describes the angle of rotation about the axis that runs through the top and bottom of the phone. The pitch describes the angle of rotation about the axis that runs through the sides of your phone (where the volume buttons are). And finally, the yaw describes the angle of rotation about the axis that runs through the front and back of your phone. With these three values, we can determine how the user is holding their phone in reference to what would be level ground (Stephan Baker).
Begin by importing CoreMotion:
import CoreMotion
Then create the following variables:
let deviceMotionDetector = CMMotionManager()
var currentAngle: Double!
We will then create a function which will check the angle of our device like so:
/// Detects The Angle Of The Device
func detectDeviceAngle(){
if deviceMotionDetector.isDeviceMotionAvailable == true {
deviceMotionDetector.deviceMotionUpdateInterval = 0.1;
let queue = OperationQueue()
deviceMotionDetector.startDeviceMotionUpdates(to: queue, withHandler: { (motion, error) -> Void in
if let attitude = motion?.attitude {
DispatchQueue.main.async {
let pitch = attitude.pitch * 180.0/Double.pi
self.currentAngle = pitch
print(pitch)
}
}
})
}
else {
print("Device Motion Unavailable");
}
}
This only needs to be called once for example in viewDidLoad:
detectDeviceAngle()
In your touchesBegan method you can add this to the end:
//1. If We Are Holding The Device Above 60 Degress Change The Node
if currentAngle > 60 {
//2a. Get The X, Y, Z Values Of The Desired Rotation
let rotation = SCNVector3(1, 0, 0)
let vector3x = rotation.x
let vector3y = rotation.y
let vector3z = rotation.z
let degreesToRotate:Float = 90
//2b. Set The Position & Rotation Of The Object
sphereNode.rotation = SCNVector4Make(vector3x, vector3y, vector3z, degreesToRotate * 180 / .pi)
}else{
}
I am sure there are better ways to achieve what you need (and I would be very interested in hearing them too), but I hope it will get you started.
Here is the result:

Why are objects in the same SKNode layer not interacting with each other?

I have less than 1 year using SpriteKit so I didn't use SKNodes as layers before until recently.
I have an SKNode layer that holds all of the fish and the user's position, for example:
var layerMainGame = SKNode()
layerMainGame.zPosition = 50
layerMainGame.addChild(userPosition)
layerMainGame.addChild(pipFish)
addChild(layerMainGame)
The interaction whether the user touched a fish or not is handled with this function, which is basically checking if their frames crossed:
if CGRectIntersectsRect(CGRectInset(node.frame, delta.dx, delta.dy), self.userPosition.frame) {
print("You got hit by \(name).")
gameOver()
}
It works. The interaction between the userPosition and pipFish works. What doesn't work is fish that are added as the game progresses. I have a function spawning different types of fish in intervals like this:
func spawnNew(fish: SKSpriteNode) {
layerMainGame.addChild(fish)
}
The interaction between the user and those fish that get added to the same layer later in the game does not work. I can pass right through them and no game over happens. When I completely remove the entire layerMainGame variable and just add them to the scene like normal, all the interactions work. Adding them all to the same SKNode layer doesn't work.
This is the function that creates a hit collision for every fish.
func createHitCollisionFor(name: String, GameOver gameOver: String!, delta: (dx: CGFloat, dy: CGFloat), index: Int = -1) {
enumerateChildNodesWithName(name) { [unowned me = self] node, _ in
if CGRectIntersectsRect(CGRectInset(node.frame, delta.dx, delta.dy), self.userPosition.frame) {
me.gameOverImage.texture = SKTexture(imageNamed: gameOver)
didGetHitActions()
me.runAction(audio.playSound(hit)!)
if index != -1 {
me.trophySet.encounterTrophy.didEncounter[index] = true
}
print("You got hit by \(name).")
}
}
}
And I call it like this:
createHitCollisionFor("GoldPiranha", GameOver: model.gameOverImage["Gold"], delta: (dx: 50, dy: 50), index: 1)
It works when the fish are not in the layer, but doesn't work when they are added to the layer.
When a node is placed in the node tree, its position property places it within a coordinate system provided by its parent.
Sprite Kit uses a coordinate orientation that starts from the bottom left corner of the screen (0, 0), and the x and y values increase as you move up and to the right.
For SKScene, the default value of the origin – anchorPoint is (0, 0), which corresponds to the lower-left corner of the view’s frame rectangle. To change it to center you can specify (0.5, 0.5)
For SKNode, the coordinate system origin is defined by its anchorPoint which by default is (0.5, 0.5) which is center of the node.
In your project you have layerMainGame added for example to the scene, his anchorPoint is by default (0.5,0.5) so the origin for the children like your fish is the center, you can see it if you change the fish positions like:
func spawnNew(fish: SKSpriteNode) {
layerMainGame.addChild(fish)
fish.position = CGPointZero // position 0,0 = parent center
}
Hope it help to understand how to solve your issue.
Update: (after your changes to the main question)
To help you better understand what happens I will give an example right away:
override func didMoveToView(view: SKView) {
var layerMainGame = SKNode()
addChild(layerMainGame)
let pipFish = SKSpriteNode(color: UIColor.yellowColor(), size: CGSizeMake(50,50))
pipFish.name = "son"
self.addChild(pipFish)
let layerPipFish = SKSpriteNode(color: UIColor.yellowColor(), size: CGSizeMake(50,50))
layerPipFish.name = "son"
layerMainGame.addChild(layerPipFish)
enumerateChildNodesWithName("son") { [unowned me = self] node, _ in
print(node)
}
}
Output:
Now I will simply change the line:
layerMainGame.addChild(layerPipFish)
with:
self.addChild(layerPipFish)
Output:
What happened?
As you can see enumerateChildNodesWithName written as your and my code print only childs directly added to self (because actually we launch enumerateChildNodesWithName which it is equal to launch self.enumerateChildNodesWithName )
How can I search in the full node tree?
If you have a node named "GoldPiranha" then you can search through all descendants by putting a // before the name. So you would search for "//GoldPiranha":
enumerateChildNodesWithName("//GoldPiranha") { [unowned me = self] ...

Centering the camera on a node in swift spritekit

I am creating a Terraria-style game in Swift. I want to have it so the player node is always in the center of the screen, and when you move right the blocks go left like in Terraria.
I am currently trying to figure out how to keep the view centered on the character. Does anyone know of a good way of accomplishing this?
Since iOS 9 / OS X 10.11 / tvOS, SpriteKit includes SKCameraNode, which makes a lot of this easier:
positioning the camera node automatically adjusts the viewport
you can easily rotate/zoom the camera by transform in the camera node
you can fix HUD elements relative to the screen by making them children of the camera node
the scene's position stays fixed, so things like physics joints don't break the way they do when you emulate a camera by moving the world
It gets even better when you combine camera nodes with another new feature, SKConstraint. You can use a constraint to specify that the camera's position is always centered on a character... or add extra constraints to say, for example, that the camera's position must stay within some margin of the edge of the world.
The below will center the camera on a specific node. It can also smoothly transition to the new position over a set time frame.
class CameraScene : SKScene {
// Flag indicating whether we've setup the camera system yet.
var isCreated: Bool = false
// The root node of your game world. Attach game entities
// (player, enemies, &c.) to here.
var world: SKNode?
// The root node of our UI. Attach control buttons & state
// indicators here.
var overlay: SKNode?
// The camera. Move this node to change what parts of the world are visible.
var camera: SKNode?
override func didMoveToView(view: SKView) {
if !isCreated {
isCreated = true
// Camera setup
self.anchorPoint = CGPoint(x: 0.5, y: 0.5)
self.world = SKNode()
self.world?.name = "world"
addChild(self.world)
self.camera = SKNode()
self.camera?.name = "camera"
self.world?.addChild(self.camera)
// UI setup
self.overlay = SKNode()
self.overlay?.zPosition = 10
self.overlay?.name = "overlay"
addChild(self.overlay)
}
}
override func didSimulatePhysics() {
if self.camera != nil {
self.centerOnNode(self.camera!)
}
}
func centerOnNode(node: SKNode) {
let cameraPositionInScene: CGPoint = node.scene.convertPoint(node.position, fromNode: node.parent)
node.parent.position = CGPoint(x:node.parent.position.x - cameraPositionInScene.x, y:node.parent.position.y - cameraPositionInScene.y)
}
}
Change what’s visible in the world by moving the camera:
// Lerp the camera to 100, 50 over the next half-second.
self.camera?.runAction(SKAction.moveTo(CGPointMake(100, 50), duration: 0.5))
Source: swiftalicio - 2D Camera in SpriteKit
For additional information, look at Apple's SpriteKit Programming Guide (Example: Centering the Scene on a Node).
You have to create World node that contains nodes. And you should put anchorPoint for example (0.5,0.5). Center on your player. And then you should move your player.
func centerOnNode(node:SKNode){
let cameraPositionInScene:CGPoint = self.convertPoint(node.position, fromNode: world!)
world!.position = CGPoint(x:world!.position.x - cameraPositionInScene.x, y: world!.position.y - cameraPositionInScene.y)
}
override func didSimulatePhysics() {
self.centerOnNode(player!)
}

Trying to get Physics Shape That Matches the Graphical Representation

what I'm trying to achieve is that the Node would have the same shape as PhysicsBody/texture (fire has a complicated shape), and then I'm trying to make only fireImage touchable. So far when I'm touching outside of the fireImage on the screen and it still makes a sound. It seems that I'm touching the squared Node, but I want to touch only the sprite/texture.
Would appreciate your help.
The code is below:
import SpriteKit
import AVFoundation
private var backgroundMusicPlayer: AVAudioPlayer!
class GameScene2: SKScene {
var wait1 = SKAction.waitForDuration(1)
override func didMoveToView(view: SKView) {
setUpScenery()
}
private func setUpScenery() {
//I'm creating a Fire Object here and trying to set its Node a physicsBody/texture shape:
let fireLayerTexture = SKTexture(imageNamed: fireImage)
let fireLayer = SKSpriteNode(texture: fireLayerTexture)
fireLayer.anchorPoint = CGPointMake(1, 0)
fireLayer.position = CGPointMake(size.width, 0)
fireLayer.zPosition = Layer.Z4st
var firedown = SKAction.moveToY(-200, duration: 0)
var fireup1 = SKAction.moveToY(10, duration: 0.8)
var fireup2 = SKAction.moveToY(0, duration: 0.2)
fireLayer.physicsBody = SKPhysicsBody(texture: fireLayerTexture, size: fireLayer.texture!.size())
fireLayer.physicsBody!.affectedByGravity = false
fireLayer.name = "fireNode"
fireLayer.runAction(SKAction.sequence([firedown, wait1, fireup1, fireup2]))
addChild(fireLayer)
}
//Here, I'm calling a Node I want to touch. I assume that it has a shape of a PhysicsBody/texture:
override func touchesEnded(touches: NSSet, withEvent event: UIEvent) {
for touch: AnyObject in touches {
let touch = touches.anyObject() as UITouch
let touchLocation = touch.locationInNode(self)
let node: SKNode = nodeAtPoint(touchLocation)
if node.name == "fireNode" {
var playguitar = SKAction.playSoundFileNamed("fire.wav", waitForCompletion: true)
node.runAction(playguitar)
}
}
}
}
Physics bodies and their shapes are for physics β€” that is, for simulating things like collisions between sprites in your scene.
Touch handling (i.e. nodeAtPoint) doesn't go through physics. That's probably the way it should be β€” if you had a sprite with a very small collision surface, you might not necessarily want it to be difficult to touch, and you also might want to be able to touch nodes that don't have physics. So your physics body doesn't affect the behavior of nodeAtPoint.
An API that lets you define a hit-testing area for a node β€” that's independent of the node's contents or physics β€” might not be a bad idea, but such a thing doesn't currently exist in SpriteKit. They do take feature requests, though.
In the meantime, fine-grained hit testing is something you'd have to do yourself. There are at least a couple of ways to do it:
If you can define the touch area as a path (CGPath or UIBezierPath/NSBezierPath), like you would for creating an SKShapeNode or a physics body using bodyWithPolygonFromPath:, you can hit-test against the path. See CGPathContainsPoint / containsPoint: / containsPoint: for the kind of path you're dealing with (and be sure to convert to the right local node coordinate space first).
If you want to hit-test against individual pixels... that'd be really slow, probably, but in theory the new SKMutableTexture class in iOS 8 / OX X v10.10 could help you. There's no saying you have to use the modifyPixelDataWithBlock: callback to actually change the texture data... you could use that once in setup to get your own copy of the texture data, then write your own hit testing code to decide what RGBA values count as a "hit".